Files
Maven/internal/auth/enrollment.go
T
2026-07-03 00:32:48 +02:00

113 lines
4.7 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"github.com/kami/maven/internal/ipc"
)
// Sentinel errors. The wire carries ErrForbidden as codeForbidden; the others
// (ErrUnenrolled distinct) floor to forbidden on the wire too — but the daemon
// log can still see the distinction server-side.
var (
// ErrForbidden — the caller's scope caps the requested authority (a
// voice-channel caller asking for L3, a poller writing an out-of-scope
// source, etc.). The wire shape: codeForbidden.
ErrForbidden = errors.New("auth: forbidden")
// ErrUnenrolled — the caller isn't recognized by the enrollment table at
// all. A distinct sentinel so the daemon can surface "this module's
// enrollment is missing" as a wiring bug, not a generic denied.
ErrUnenrolled = errors.New("auth: caller not enrolled")
)
// Enrollment — the impure seam mapping a connecting process to a Scope. The
// floor below trusts same-uid local callers fully (the 0600-floor equivalent),
// production wires the real enrollment table reading a Config / sqlite table.
//
// Lookup is the single impure call per dispatch; Can and Check downstream are
// pure. That keeps "no module ever reads its own authority" checkable:
// anywhere outside the Enrollment impl doing caller→scope resolution is a bug.
type Enrollment interface {
// Lookup resolves the caller's Scope. Return ErrUnenrolled when the caller
// isn't recognized; ErrForbidden when recognized but refused for policy
// reasons (e.g. disabled module); any other error for I/O failure.
// hasCaller=false is the in-process path (no ipc.Caller attached to ctx).
Lookup(ctx context.Context, c ipc.Caller, hasCaller bool) (Scope, error)
}
// FloorEnrollment — today's auth floor. Same as the socket's 0600 perms: any
// same-uid caller is trusted as a "core" module (SurfaceCoreProcess, L3,
// write-any-source). The in-process path (no Caller) is the same: it's the
// daemon itself, holding the unlocked store, so it gets L3 trivially.
//
// This is the AUTH FLOOR, not the auth model — the spec's invariant (surface
// caps authority, source-scope, step-up) is shaped in policy.go and exercised
// in tests through tighter enrollments. The daemon swaps this out when the
// real enrollment table lands; nothing downstream changes.
type FloorEnrollment struct {
// Module is the label FloorEnrollment stamps on every caller (default
// "core"). Real enrollment derives this from Caller.Uid/Pid.
Module string
}
// NewFloorEnrollment — default "core" module, full source scope, L3 cap.
// This preserves the prior (pre-auth) behavior: any same-uid caller was
// permitted everything. Compiles to identity authority.
func NewFloorEnrollment() *FloorEnrollment { return &FloorEnrollment{Module: "core"} }
// Lookup — same-uid floor. HasCaller=false ⇒ in-process path (trusted "core");
// HasCaller=true ⇒ for now we still trust (only same-uid can connect via the
// 0600 socket perms). The real enrollment table replaces this with a lookup
// keyed on Uid/Pid → Module entry.
func (f *FloorEnrollment) Lookup(_ context.Context, _ ipc.Caller, _ bool) (Scope, error) {
return Scope{
Surface: SurfaceCoreProcess,
Module: f.Module,
SourceScope: []string{"*"},
}, nil
}
// StaticEnrollment — a hand-built enrollment for tests and demos: map every
// caller exact-match on Uid to a fixed Scope. Pure-ish (no I/O); the daemon
// holds it and lets the operator append at runtime; tests build their own.
// Used to model "a poller that can only write poll:healthcheck" — the spec's
// compromised-poller scenario — without standing up the full enrollment table.
type StaticEnrollment struct {
// ByUid — uid-keyed scope. Mutated to add an enrolled module.
ByUid map[int32]Scope
// InProcess is the scope returned for in-process (HasCaller=false) calls.
// nil ⇒ falls through to Default.
InProcess *Scope
// Default is returned when no specific entry matches. nil ⇒ ErrUnenrolled
// (fail closed).
Default *Scope
}
// Lookup walks the static map. hasCaller ⇒ ByUid → Default → ErrUnenrolled;
// in-process ⇒ InProcess → Default → ErrUnenrolled. Fail closed everywhere,
// because a StaticEnrollment is built deliberately and any unmatched caller
// is exactly the "who is this?" case the real table answers.
func (s *StaticEnrollment) Lookup(_ context.Context, c ipc.Caller, hasCaller bool) (Scope, error) {
if !hasCaller {
if s.InProcess != nil {
return *s.InProcess, nil
}
if s.Default != nil {
return *s.Default, nil
}
return Scope{}, ErrUnenrolled
}
if sc, ok := s.ByUid[c.Uid]; ok {
return sc, nil
}
if s.Default != nil {
return *s.Default, nil
}
return Scope{}, fmt.Errorf("%w (uid=%d)", ErrUnenrolled, c.Uid)
}