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

121 lines
5.6 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/kami/maven/internal/ipc"
)
// Gate — wraps Enrollment + optional Session state and exposes a CheckFunc
// the daemon wires into ipc.Server.Check. The ONE place a wire call gets
// authorized. Adding the auth layer does not change CoreAPI, dispatch, or
// module code; the daemon constructs a Gate and sets Server.Check = gate.Check.
//
// floors:
// - FloorEnrollment ⇒ no change from pre-auth behavior (same-uid trusted,
// full source scope).
// - nil Session ⇒ step-up never asserted; any AuthStepUp call refused
// (surface caps + session caps agree to fail closed). Today's CoreAPI has
// no AuthStepUp methods, so nil Session is the daemon floor.
type Gate struct {
Enrollment Enrollment
Session Session // nil ⇒ step-up not asserted
}
// Session — the per-session step-up state. The impure seam the passkey
// verifier implements: Assert records a successful user-verification gesture,
// CurrentLayer returns how high the session is asserted right now (cold boot ⇒
// not-asserted; passkey challenge ⇒ L3 for the session lifetime). Without a
// Session wired, step-up-requiring calls (EnableTool, cold-start unlock) fail
// closed — the gate can't grant what wasn't demonstrated.
//
// Today the daemon runs no interface that asserts session step-up (no pc client
// yet); the floor is nil Session ⇒ AuthStepUp always refused. The shape is
// here so the passkey verifier is a single new impl, not a dispatch change.
type Session interface {
// CurrentLayer returns the authority the session currently carries.
// Outside a step-up window, returns the layer the surface can carry on its
// own minus the step-up contribution (e.g. a pc_client without asserted
// step-up returns Layer2; the passkey gesture bumps it to Layer3 for the
// session lifetime).
CurrentLayer(ctx context.Context, scope Scope) Layer
// Assert — record a successful step-up gesture for scope. The passkey
// verifier returns nil and the daemon-queried Session treats this session
// as L3 until it expires. Floor impls may return ErrStepUpUnsupported.
Assert(ctx context.Context, scope Scope) error
}
// ErrStepUpUnsupported — returned by floor Session.Assert when no passkey
// verifier is wired. Distinct from ErrForbidden: a missing impl is a wiring
// bug, not a denial.
var ErrStepUpUnsupported = errors.New("auth: step-up not supported by this session")
// FloorSession — the step-up floor, mirroring FloorEnrollment: any caller that
// cleared the 0600 socket is trusted as fully step-asserted (L3). It exists so
// the floor is CONSISTENT — FloorEnrollment already grants same-uid callers L3
// for writes; without a matching Session floor, AuthStepUp methods (EnableTool)
// would be refused for the same callers, an accidental asymmetry. The real
// passkey verifier replaces this (a single new Session impl, no dispatch change)
// so step-up becomes a real gesture instead of a floor grant.
type FloorSession struct{}
// CurrentLayer — the floor trusts the local caller fully.
func (FloorSession) CurrentLayer(_ context.Context, _ Scope) Layer { return Layer3 }
// Assert — a no-op success at the floor (the caller is already trusted).
func (FloorSession) Assert(_ context.Context, _ Scope) error { return nil }
// Check — the authorization hook. Wired into ipc.Server.Check (single
// insertion point). Shape: nil ipc.Caller ⇒ in-process path (Lookup with
// hasCaller=false). Otherwise resolve via Enrollment; refuse on any error;
// run pure Can on the resolved scope + raw params.
//
// Returns ErrForbidden (mirrored to codeForbidden on the wire) for any
// authority failure; bubbles other errors (Enrollment I/O, ErrUnenrolled)
// up to dispatch where they're mapped to codeInternal or codeForbidden
// depending on identity-ness. We map ErrUnenrolled → forbidden: an unknown
// caller is not surfaced as "internal error" to a module.
func (g *Gate) Check(ctx context.Context, m ipc.Method, params json.RawMessage) error {
caller, hasCaller := ipc.CallerFrom(ctx)
scope, err := g.Enrollment.Lookup(ctx, caller, hasCaller)
if err != nil {
// Unenrolled ⇒ forbidden on the wire (codeForbidden). I/O failures of
// the enrollment table are not "the caller is unauthorized"; they
// bubble as internal via codeOf's default. Wrap with both sentinels
// (Go 1.20+ multi-%w) so:
// - ipc.codeOf resolves to codeForbidden via errors.Is(ipc.ErrForbidden)
// - auth-package tests resolve via errors.Is(auth.ErrForbidden)
// - the daemon log carries both names.
if errors.Is(err, ErrUnenrolled) {
return fmt.Errorf("%w: %w", ipc.ErrForbidden, err)
}
return err
}
if err := Can(m, scope, params); err != nil {
// Can already uses auth.ErrForbidden / ErrUnenrolled inside; we wrap
// with ipc.ErrForbidden so codeOf resolves to codeForbidden at the
// wire. The auth sentinel stays in the chain via %w (not %v) so
// errors.Is(auth.ErrForbidden) works in auth-package tests.
if errors.Is(err, ErrUnenrolled) || errors.Is(err, ErrForbidden) {
return fmt.Errorf("%w: %w", ipc.ErrForbidden, err)
}
return err
}
// AuthStepUp verdict's surface-cap half is in Can. The session-level
// check (was step-up actually asserted THIS session?) lives here so the
// Session owns its own state; Can stays pure-data.
if Requirement(m) == AuthStepUp {
if g.Session == nil {
return fmt.Errorf("%w: %w: AuthStepUp but no Session wired", ipc.ErrForbidden, ErrForbidden)
}
if g.Session.CurrentLayer(ctx, scope) < Layer3 {
return fmt.Errorf("%w: %w: step-up not asserted this session", ipc.ErrForbidden, ErrForbidden)
}
}
return nil
}