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 }