45b5e16eff
Things arrive at Maven from eight directions — a relayed Android notification on POST /api/ambient, mail candidates from mavmaild, RSS items, changed pages from the crawler, zenmoney and wg reads from mavpoll, CalDAV events, presence probes, meeting transcripts and image descriptions. Each grew its own shape and its own log line, and nothing could answer "what came in today, from where". internal/event is that answer: a flat source-agnostic envelope (Source, Kind, EntityIDs, Title, Body, Priority, OccurredAt, Payload) plus a bounded in-memory journal. Both are pure — Publish and Normalize take `now` as a parameter, so no clock read sits on a path a replay would drive. Adopting it did not touch eight callers, because every intake path already converges on three ipc.CoreAPI methods: WriteFact, WriteNote and CaptureTask. cmd/mavend/intake.go decorates that ONE interface, so mavweb, mavcaldav, mavpoll, mavmaild and the in-core feed/crawl/capture/ vision workers publish envelopes without knowing events exist. The lone exception is cmd/mavend/mail.go, which captures through the store directly and now publishes explicitly. Nothing dispatches on an event. It is a report that something arrived, never an instruction to speak — "a feed item appeared" becoming a notification is the nag this repo refuses. Digestion may read the journal later; it will still go through internal/loop's rules and the severity/presence routing table. Read surface: ipc.MethodRecentEvents (AuthRead, daemon-cached like TickTrace — a bare store cannot serve a ring) and a read-only /events page in mavweb. Production is unchanged when nobody is watching: a nil *event.Bus makes Publish a no-op and newIntakeAPI returns the wrapped API untouched, so config.intake_journal < 0 leaves no decorator on the call path at all. The default is 512 entries; the "off unless configured" rule is for capabilities that reach out, and a bounded in-memory log of writes core already performed reaches nowhere. Verified: make build, make test (go test -race) both clean. New tests cover the envelope and ring (internal/event, 95.7%), the decorator's invariants — a failed write publishes nothing, a deduped capture publishes nothing, OccurredAt is the fact's Ts and not notice time — and the /events page including escaping of feed-supplied titles.
250 lines
11 KiB
Go
250 lines
11 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// Authority — a discrete authority requirement per ipc.Method. Higher
|
|
// numbers are STRICTER (need a higher layer to be granted). Today's CoreAPI
|
|
// methods are all read or single-module-write; the deferred L3 acts
|
|
// (EnableTool / destructive Ops) are reserved at the top rung — they're
|
|
// not on the CoreAPI yet (the tool-executor module is unbuilt), but the
|
|
// authority table holds the rung so adding them is a policy entry, not a
|
|
// new mechanism.
|
|
type Authority int8
|
|
|
|
const (
|
|
// AuthRead — read methods (LatestFact, LatestFactBySource, Since, Presence,
|
|
// RecentOutcomes) and state mutations a module legitimately makes
|
|
// (CreateReminder, MarkReminder, RecordNudge, ResolveNudge). The Enrollment
|
|
// already gated caller identity; any enrolled module may use these.
|
|
AuthRead Authority = 0
|
|
|
|
// AuthWrite — WriteFact. Need enrollment + source-scope match. The
|
|
// "compromised poller can't forge a trigger" property: a module only writes
|
|
// sources it owns. Floor gets "*"; tight enrollments scope per source.
|
|
AuthWrite Authority = 1
|
|
|
|
// AuthStepUp — a per-assertion user-verification gesture is required for
|
|
// this call. Reserved for EnableTool (registration-enable) and destructive
|
|
// acts when they land on the CoreAPI. NOT a Layer itself — Authority is
|
|
// the call-side requirement; Layer is the surface-side capability. The
|
|
// pure check is just: does the surface cap (MaxLayer) carry L3, AND was
|
|
// step-up asserted this session? Both settled by Can below.
|
|
AuthStepUp Authority = 2
|
|
)
|
|
|
|
// Requirement — the PURE authority table: per-method required Authority. This
|
|
// is the one place in the codebase a method's required authority is declared;
|
|
// every other reference to "registration needs step-up" points back here.
|
|
// Adding a new ipc.Method = a row here (or it inherits AuthRead by default,
|
|
// which the vet check in dispatch catches via Method existence, not auth).
|
|
func Requirement(m ipc.Method) Authority {
|
|
switch m {
|
|
case ipc.MethodEnableTool, ipc.MethodDisableTool:
|
|
// Both mutate the tool allowlist — the boundary. Enable adds a runnable
|
|
// capability (privilege escalation); disable removes one (fail-safe
|
|
// direction, but still an allowlist mutation and a lever an attacker
|
|
// could pull to silence a security-relevant tool). Human-only, step-up
|
|
// asserted — never a module or the voice/chat path. maven can propose
|
|
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
|
return AuthStepUp
|
|
case ipc.MethodSwapModel:
|
|
// Swapping the resident model changes what routes every utterance and
|
|
// what words every reply. It is the owner's call, from a surface that can
|
|
// carry a passkey gesture — the same rung as mutating the tool allowlist,
|
|
// and for the same reason: nothing Maven says or does may reach it.
|
|
// MethodModelStatus is only the read side, so it stays at AuthRead.
|
|
return AuthStepUp
|
|
case ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop:
|
|
// Recording a meeting (Vikunja #253). AuthWrite, not AuthRead: it puts
|
|
// audio of other people on disk, which is a heavier thing than reading a
|
|
// fact, and it is not something a read-only surface should be able to
|
|
// begin. Append and Stop sit on the same rung as Start deliberately —
|
|
// a surface that may not start a recording has no business feeding or
|
|
// harvesting one either.
|
|
//
|
|
// Not AuthStepUp, and this is the interesting line: step-up needs a
|
|
// passkey gesture, which the voice path cannot make. Putting it here
|
|
// would mean "запиши встречу" could never work by voice, and the real
|
|
// gate on this capability is elsewhere and stronger — the methods do not
|
|
// exist at all unless the operator enabled a capture block, and no
|
|
// recording can begin without someone saying so.
|
|
return AuthWrite
|
|
case ipc.MethodEnrollSpeaker:
|
|
// Taking a voiceprint (Vikunja #255). AuthStepUp, and unlike recording a
|
|
// meeting there is no reason to soften it: enrolment is not a thing anyone
|
|
// does by voice mid-conversation. It is a deliberate sit-down with a
|
|
// surface that can carry a passkey gesture, and it writes a biometric of a
|
|
// named person. If the gesture is inconvenient, that is the correct amount
|
|
// of friction for this particular write.
|
|
return AuthStepUp
|
|
case ipc.MethodForgetSpeaker:
|
|
// Deleting a voiceprint. One rung BELOW enrolment on purpose. Everywhere
|
|
// else in this table the destructive direction is gated at least as hard
|
|
// as the constructive one, and here that would be wrong: getting rid of a
|
|
// biometric must never be the harder half. The worst a caller at this rung
|
|
// can do is make Maven stop recognising someone, which is the state the
|
|
// box ships in anyway.
|
|
return AuthWrite
|
|
case ipc.MethodWriteFact:
|
|
return AuthWrite
|
|
case ipc.MethodAssertStepUp:
|
|
return AuthRead
|
|
case ipc.MethodLatestFact,
|
|
ipc.MethodLatestFactBySource,
|
|
ipc.MethodSince,
|
|
ipc.MethodPresence,
|
|
ipc.MethodRecentOutcomes,
|
|
ipc.MethodCreateReminder,
|
|
ipc.MethodMarkReminder,
|
|
ipc.MethodRecordNudge,
|
|
ipc.MethodResolveNudge,
|
|
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
|
// the default so the intent is on the record: capturing a task is a
|
|
// module write, not an allowlist mutation and not a new standing reason
|
|
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
|
// at AuthRead, the same rung as CreateReminder, which is the closest
|
|
// existing analogue.
|
|
ipc.MethodCaptureTask,
|
|
ipc.MethodListTasks,
|
|
ipc.MethodSetTaskStatus,
|
|
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
|
|
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
|
// reminder, or touch the tool allowlist, so a compromised mail reader can
|
|
// at worst put junk on a review page he clears in one click.
|
|
ipc.MethodIngestMail,
|
|
// Looking at one image (Vikunja #252). AuthRead because of what it can
|
|
// produce: words about a picture, and optionally a note. It cannot write
|
|
// a fact, set a reminder, or touch the tool allowlist. The invasive part
|
|
// of this capability is not the authority rung — it is that the bytes are
|
|
// kept on disk, which media.retention bounds, and that they never leave
|
|
// the box, which internal/vision enforces by refusing a non-private
|
|
// endpoint.
|
|
ipc.MethodDescribeImage,
|
|
// "что ты записываешь?" — the read side of the recorder. It reports a
|
|
// label, a start time and a byte count, begins nothing and keeps nothing.
|
|
ipc.MethodCaptureStatus,
|
|
// Who is enrolled. Returns ids, names and enrolment dates — never the
|
|
// voiceprints themselves, which stay in core. Listing the people Maven can
|
|
// recognise is exactly the read a surface needs to offer a "forget" button.
|
|
ipc.MethodListSpeakers,
|
|
// The read side of the model swap: which model is resident, which ones are
|
|
// allowlisted. It loads nothing and changes nothing.
|
|
ipc.MethodModelStatus,
|
|
// The unified intake journal (Vikunja #283). AuthRead, and listed
|
|
// explicitly rather than inherited so the reasoning is on the record: it
|
|
// reports what already arrived — sources, keys, note headlines — which is
|
|
// the same material RecentFacts and RecentNotes already return at this
|
|
// rung. It writes nothing, and it holds nothing a fact read does not.
|
|
ipc.MethodRecentEvents:
|
|
return AuthRead
|
|
}
|
|
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
|
// regardless of the auth verdict (we run before dispatch; we don't gate on
|
|
// Method existence — Check is method-agnostic policy, not routing).
|
|
return AuthRead
|
|
}
|
|
|
|
// Can — the PURE authority decision for one call. Returns nil if the scope
|
|
// is authorized to invoke m with the supplied params; an error otherwise:
|
|
//
|
|
// - ErrUnenrolled — scope has no Module (caller wasn't in the enrollment
|
|
// table). Fail closed.
|
|
// - ErrForbidden — surface caps the layer below what m requires, or
|
|
// WriteFact's source is out of scope.
|
|
//
|
|
// The adapter wrapping this for the ipc gate (Gate.Check) maps the error to
|
|
// codeForbidden at the wire; we keep the distinction here so daemon logs
|
|
// can show why a call was denied.
|
|
//
|
|
// params is the raw json.RawMessage the ipc Server received; for WriteFact we
|
|
// re-parse Source out of it. Other methods don't need params — their verdict
|
|
// depends only on the scope.
|
|
func Can(m ipc.Method, scope Scope, params json.RawMessage) error {
|
|
// Floor closed: any caller not in the enrollment table is refused outright.
|
|
// Not "0-level unauthed" — refused. This is the surface-caps property
|
|
// applied before the layer caps: there is no L0 surface if SurfaceUnknown.
|
|
if scope.Module == "" {
|
|
return ErrUnenrolled
|
|
}
|
|
if scope.Surface == SurfaceUnknown {
|
|
return ErrUnenrolled
|
|
}
|
|
|
|
switch Requirement(m) {
|
|
case AuthRead:
|
|
// Any enrolled module may read. Reads through the surface level the
|
|
// Enrollment set (voice-L0 wouldn't be enrolled to write at all).
|
|
return nil
|
|
|
|
case AuthWrite:
|
|
if m == ipc.MethodWriteFact {
|
|
src, err := extractSource(params)
|
|
if err != nil {
|
|
// Malformed params is a bad-params error already produced by
|
|
// ipc.dispatch; but Can runs first. Treat as forbidden — a
|
|
// caller doesn't get to probe scopes with garbage params.
|
|
return fmt.Errorf("%w: malformed source", ErrForbidden)
|
|
}
|
|
if !SourceAllowed(scope.SourceScope, src) {
|
|
// The spec's compromised-poller case in one line: a poller
|
|
// enrolled to write poll:healthcheck asking to write
|
|
// poll:uptime is denied — but the same poller writing
|
|
// poll:healthcheck is fine. Polls can't forge triggers.
|
|
return fmt.Errorf("%w: source %q out of scope", ErrForbidden, src)
|
|
}
|
|
}
|
|
return nil
|
|
|
|
case AuthStepUp:
|
|
// Surface-caps-authority enforced here. The surface can't carry L3 ⇒
|
|
// forbidden. The session step-up itself is checked by the Gate (it
|
|
// owns Session state and surfaces a Check function); we cap surface
|
|
// here so the gate fails closed on shape alone.
|
|
if MaxLayer(scope.Surface) < Layer3 {
|
|
return fmt.Errorf("%w: surface %s can't carry step-up", ErrForbidden, scope.Surface)
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SourceAllowed — true iff src is in scope (the wildcard "*" matches all).
|
|
// Empty scope ⇒ fail closed. The function is pure; we keep it exported so a
|
|
// future enrollment table can call into the same matching logic.
|
|
func SourceAllowed(scope []string, src string) bool {
|
|
if len(scope) == 0 {
|
|
return false
|
|
}
|
|
for _, s := range scope {
|
|
if s == "*" || s == src {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractSource reads WriteFactReq.Source out of the raw params WITHOUT a full
|
|
// unmarshal — Source is the only field Can needs, and re-parsing it once per
|
|
// write is cheap (and only happens on MethodWriteFact). Stay independent of
|
|
// any future WriteFactReq shape changes by using the struct directly.
|
|
func extractSource(raw json.RawMessage) (string, error) {
|
|
var p ipc.WriteFactReq
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return "", err
|
|
}
|
|
if p.Source == "" {
|
|
// An empty source is rejected by store.WriteFact anyway; surface it
|
|
// as forbidden to avoid giving a caller an ipc sentinel that names the
|
|
// store's internal invariant. (store rejects this before feature flag
|
|
// for "missing source"; today this is best-effort.)
|
|
return "", errors.New("empty source")
|
|
}
|
|
return p.Source, nil
|
|
}
|