113 lines
4.7 KiB
Go
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)
|
|
}
|