7b2b96b957
A task is not a fact and not a note. A fact is a claim about the world that a correction supersedes; a note is something to recall by meaning. A task is work with a lifecycle, and the read that matters is "everything outstanding right now" — which over an append-only log would mean replaying history on every question. So: a tasks table, migration #14, statuses candidate/open/done/dropped that each move forward exactly once. Dedupe is on normalised text among LIVE rows only, via a partial unique index. That is the property the mail side needs: an extractor may call CaptureTask for every message it reads, as often as it likes, without growing the list — while a weekly errand is still capturable again once the last one is done. Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path (router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never "надо бы поспать"), the /tasks form, and the email reader from #246 when it exists. Mail-derived items set Source "email:<account>", Status "candidate" and Evidence to whatever makes the row reviewable; a candidate is inert until he confirms it on /tasks, and Maven names it as unconfirmed when she recites the list rather than putting words in his mouth. No new intent — the router enum is a contract with the relabelling prompt, so capture rides the note intent and the list rides a query source, both matched deterministically like the calendar and plan matchers already are. Nothing here speaks. No tick rule reads tasks; the list is answered when asked about, which is why /tasks POST is not step-up gated the way /tools and /routines are — a task write moves no boundary. Vikunja #130
183 lines
7.3 KiB
Go
183 lines
7.3 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.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:
|
|
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
|
|
}
|