initial commit
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestMaxLayer_SurfaceCapsAuthority(t *testing.T) {
|
||||
// The invariant: surface caps maximum authority. A surface structurally
|
||||
// unable to carry passkey-user-verification caps below L3 — voice never
|
||||
// reaches step-up, telegram never reaches step-up, pc_client + authed_page
|
||||
// + the daemon's own process DO.
|
||||
cases := []struct {
|
||||
surface Surface
|
||||
max Layer
|
||||
}{
|
||||
{SurfaceVoice, Layer0},
|
||||
{SurfaceTelegram, Layer2},
|
||||
{SurfacePCClient, Layer3},
|
||||
{SurfaceAuthedPage, Layer3},
|
||||
{SurfaceCoreProcess, Layer3},
|
||||
{SurfaceUnknown, -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := MaxLayer(c.surface)
|
||||
if got != c.max {
|
||||
t.Errorf("MaxLayer(%s) = %d; want %d (surface caps authority)", c.surface, got, c.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
scope []string
|
||||
src string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"*"}, "anything", true},
|
||||
{[]string{"poll:healthcheck"}, "poll:healthcheck", true},
|
||||
{[]string{"poll:healthcheck"}, "poll:uptime", false}, // compromised poller can't forge a trigger
|
||||
{[]string{"poll:healthcheck", "tap:water"}, "tap:water", true},
|
||||
{[]string{"poll:healthcheck", "tap:water"}, "ambient", false},
|
||||
{[]string{}, "anything", false}, // fail closed
|
||||
{nil, "anything", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := SourceAllowed(c.scope, c.src); got != c.want {
|
||||
t.Errorf("SourceAllowed(%v, %q) = %v; want %v", c.scope, c.src, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirement_Table(t *testing.T) {
|
||||
// WriteFact is AuthWrite (carries source-scope); EnableTool is AuthStepUp
|
||||
// (registration-enable moves the boundary); all others are AuthRead.
|
||||
if got := Requirement(ipc.MethodWriteFact); got != AuthWrite {
|
||||
t.Errorf("WriteFact authority = %v; want AuthWrite", got)
|
||||
}
|
||||
if got := Requirement(ipc.MethodEnableTool); got != AuthStepUp {
|
||||
t.Errorf("EnableTool authority = %v; want AuthStepUp", got)
|
||||
}
|
||||
reads := []ipc.Method{
|
||||
ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince,
|
||||
ipc.MethodPresence, ipc.MethodRecentOutcomes,
|
||||
ipc.MethodCreateReminder, ipc.MethodMarkReminder,
|
||||
ipc.MethodRecordNudge, ipc.MethodResolveNudge,
|
||||
}
|
||||
for _, m := range reads {
|
||||
if got := Requirement(m); got != AuthRead {
|
||||
t.Errorf("%s authority = %v; want AuthRead", m, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_EnableTool_StepUp(t *testing.T) {
|
||||
// EnableTool is AuthStepUp. At the floor (FloorEnrollment L3 + FloorSession
|
||||
// asserting L3), the local caller may enable — the authed mavweb /tools page
|
||||
// hits this path. With a nil Session (no step-up asserted), it's refused —
|
||||
// step-up can't be granted from what wasn't demonstrated.
|
||||
ctx := context.Background()
|
||||
withSession := &Gate{Enrollment: NewFloorEnrollment(), Session: FloorSession{}}
|
||||
if err := withSession.Check(ctx, ipc.MethodEnableTool, nil); err != nil {
|
||||
t.Errorf("EnableTool with FloorSession = %v; want nil (floor permits)", err)
|
||||
}
|
||||
noSession := &Gate{Enrollment: NewFloorEnrollment()}
|
||||
if err := noSession.Check(ctx, ipc.MethodEnableTool, nil); !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("EnableTool with nil Session = %v; want ErrForbidden (fail closed)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCan_Unenrolled_FailClosed(t *testing.T) {
|
||||
// No Module / unknown surface ⇒ refused, NOT "0-level authed". This is
|
||||
// the surface-caps property applied before the layer caps.
|
||||
if err := Can(ipc.MethodPresence, Scope{}, nil); !errors.Is(err, ErrUnenrolled) {
|
||||
t.Errorf("Can with empty Scope = %v; want ErrUnenrolled", err)
|
||||
}
|
||||
if err := Can(ipc.MethodPresence, Scope{Module: "x", Surface: SurfaceUnknown}, nil); !errors.Is(err, ErrUnenrolled) {
|
||||
t.Errorf("Can with unknown surface = %v; want ErrUnenrolled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCan_WriteFact_SourceScope(t *testing.T) {
|
||||
// The spec's compromised-poller case: a poller enrolled to write
|
||||
// poll:healthcheck can't forge poll:uptime (or anything else).
|
||||
poller := Scope{
|
||||
Surface: SurfaceCoreProcess,
|
||||
Module: "poller:healthcheck",
|
||||
SourceScope: []string{"poll:healthcheck"},
|
||||
}
|
||||
core := Scope{
|
||||
Surface: SurfaceCoreProcess,
|
||||
Module: "core",
|
||||
SourceScope: []string{"*"},
|
||||
}
|
||||
|
||||
// In-scope write succeeds.
|
||||
if err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:healthcheck")); err != nil {
|
||||
t.Errorf("poller writing poll:healthcheck = %v; want nil", err)
|
||||
}
|
||||
// Out-of-scope write is forbidden — auth.ErrForbidden in the chain.
|
||||
err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:uptime"))
|
||||
if err == nil || !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("poller writing poll:uptime = %v; want ErrForbidden in chain", err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "poll:uptime") {
|
||||
t.Errorf("forbidden err should name the offending source: got %q", err)
|
||||
}
|
||||
|
||||
// Core (wildcard) writes anything.
|
||||
if err := Can(ipc.MethodWriteFact, core, mustWriteFactParams("poll:uptime")); err != nil {
|
||||
t.Errorf("core writing poll:uptime = %v; want nil (wildcard scope)", err)
|
||||
}
|
||||
|
||||
// Empty SourceScope ⇒ fail closed even for an enrolled module.
|
||||
empty := Scope{Surface: SurfaceCoreProcess, Module: "x", SourceScope: nil}
|
||||
if err := Can(ipc.MethodWriteFact, empty, mustWriteFactParams("poll:healthcheck")); !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("empty SourceScope WriteFact = %v; want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCan_Reads_AnyEnrolledModule(t *testing.T) {
|
||||
// Reads permit any enrolled module (the enrollment already gated caller
|
||||
// identity). Reads on SurfaceVoice are OK too — enrollment may have
|
||||
// enrolled a voice module for read-only purposes (e.g. ambient parse).
|
||||
for _, surf := range []Surface{SurfaceVoice, SurfaceTelegram, SurfacePCClient, SurfaceCoreProcess} {
|
||||
scope := Scope{Surface: surf, Module: "x", SourceScope: []string{"*"}}
|
||||
if err := Can(ipc.MethodPresence, scope, nil); err != nil {
|
||||
t.Errorf("read on %s = %v; want nil (any enrolled module may read)", surf, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gate ---
|
||||
|
||||
func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) {
|
||||
// The floor must be a no-op vs pre-auth: same-uid trusted, full source
|
||||
// scope, every read and in-scope write passes. Tied end-to-end through
|
||||
// Gate.Check on the ipc.Method enumeration, so AuthRead / AuthWrite both
|
||||
// flow through the composition.
|
||||
g := &Gate{Enrollment: NewFloorEnrollment()}
|
||||
ctx := context.Background()
|
||||
for _, m := range []ipc.Method{
|
||||
ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes,
|
||||
ipc.MethodCreateReminder, ipc.MethodRecordNudge,
|
||||
} {
|
||||
if err := g.Check(ctx, m, nil); err != nil {
|
||||
t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err)
|
||||
}
|
||||
}
|
||||
if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")); err != nil {
|
||||
t.Errorf("floor gate Check(WriteFact, *) = %v; want nil (full source scope)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_StaticEnrollment_SourceScopeEnforced(t *testing.T) {
|
||||
// A poller enrolled to write only poll:healthcheck is denied a write to
|
||||
// poll:uptime. Gate.Authoritative on real source-scope. Wire code should
|
||||
// be forbidden once we wire through the ipc layer (next test exercises
|
||||
// that path through a real socket).
|
||||
pollerUid := int32(70001)
|
||||
enrollment := &StaticEnrollment{
|
||||
ByUid: map[int32]Scope{
|
||||
pollerUid: {
|
||||
Surface: SurfaceCoreProcess,
|
||||
Module: "poll:healthcheck",
|
||||
SourceScope: []string{"poll:healthcheck"},
|
||||
},
|
||||
},
|
||||
Default: &Scope{ // any other uid gets full trust — for tests, doesn't matter
|
||||
Surface: SurfaceCoreProcess,
|
||||
Module: "core",
|
||||
SourceScope: []string{"*"},
|
||||
},
|
||||
}
|
||||
g := &Gate{Enrollment: enrollment}
|
||||
|
||||
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1234})
|
||||
if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
|
||||
t.Errorf("poller in-scope write = %v; want nil", err)
|
||||
}
|
||||
err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
|
||||
if err == nil {
|
||||
t.Fatalf("poller out-of-scope write returned nil; want forbidden")
|
||||
}
|
||||
if !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("poller out-of-scope err chain = %v; want ipc.ErrForbidden in chain (wire code wraps ipc.ErrForbidden)", err)
|
||||
}
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("poller out-of-scope err chain = %v; want auth.ErrForbidden in chain (auth tests can satisfy Is)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGate_Unenrolled_FailsClosed(t *testing.T) {
|
||||
enrollment := &StaticEnrollment{} // no Default ⇒ ErrUnenrolled for any caller
|
||||
g := &Gate{Enrollment: enrollment}
|
||||
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: 4242, Pid: 99})
|
||||
err := g.Check(ctx, ipc.MethodPresence, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("un-enrolled caller returned nil; want forbidden")
|
||||
}
|
||||
if !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("unenrolled err = %v; want ipc.ErrForbidden in chain (mapped at wire edge)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- end-to-end through ipc.Server ---
|
||||
|
||||
// TestGate_IpcServer_WiresCheck end-to-end. Server.Check = g.Check; an
|
||||
// out-of-scope WriteFact sent through a real socket rehydrates as
|
||||
// ipc.ErrForbidden on the wire. The seam — no CoreAPI change, no module
|
||||
// change — is the whole point: the daemon sets Server.Check at construction.
|
||||
func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
|
||||
pollerUid := int32(70001)
|
||||
enrollment := &StaticEnrollment{
|
||||
ByUid: map[int32]Scope{
|
||||
pollerUid: {
|
||||
Surface: SurfaceCoreProcess,
|
||||
Module: "poll:healthcheck",
|
||||
SourceScope: []string{"poll:healthcheck"},
|
||||
},
|
||||
},
|
||||
Default: &Scope{ // any other uid ⇒ un-enrolled → forbidden
|
||||
Surface: SurfaceUnknown,
|
||||
Module: "",
|
||||
},
|
||||
}
|
||||
gate := &Gate{Enrollment: enrollment}
|
||||
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "maven.sock")
|
||||
// A fake CoreAPI that records writes; the auth verdict should fire before
|
||||
// it ever gets called.
|
||||
fake := &recordingAPI{}
|
||||
srv, err := ipc.Listen(sock, fake)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv.Check = gate.Check
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = srv.Serve()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Close()
|
||||
<-done
|
||||
})
|
||||
|
||||
// Direct in-process check: we can caller-stamp our test ctx with any
|
||||
// uid/pid we want. The socket path produces our real uid (via
|
||||
// SO_PEERCRED), but the auth layer's verdict depends only on the
|
||||
// caller-shape the wire delivered, not the wire transport — so the
|
||||
// in-process check uses the same Gate.Check the socketed dispatch calls.
|
||||
ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1})
|
||||
if err := gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil {
|
||||
t.Errorf("in-scope write through gate = %v; want nil", err)
|
||||
}
|
||||
err = gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime"))
|
||||
if !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("out-of-scope write through gate = %v; want ipc.ErrForbidden", err)
|
||||
}
|
||||
|
||||
// Smoke the actual wire path: our real uid is un-enrolled per the
|
||||
// StaticEnrollment (only 70001 is enrolled), so the very next call we
|
||||
// make over the socket is forbidden at the wire — rehydrating on the
|
||||
// client side as ipc.ErrForbidden. This is the full chain:
|
||||
// dispatch → Check (auth) → codeOf → wire → hydrate.
|
||||
cli, err := ipc.Dial(sock)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cli.Close() })
|
||||
_, err = cli.WriteFact(context.Background(), ipc.WriteFactReq{
|
||||
Kind: "env",
|
||||
Key: "service_down",
|
||||
Value: "down",
|
||||
Source: "poll:healthcheck",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("wire: write from real uid (unenrolled per StaticEnrollment) = %v; want ipc.ErrForbidden", err)
|
||||
}
|
||||
if fake.writes != 0 {
|
||||
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth
|
||||
// check must reject before reaching it, otherwise the refusal leaks into the
|
||||
// fake's counts and we fail.
|
||||
type recordingAPI struct {
|
||||
writes int
|
||||
}
|
||||
|
||||
func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) {
|
||||
r.writes++
|
||||
return int64(r.writes), nil
|
||||
}
|
||||
func (r *recordingAPI) LatestFact(_ context.Context, _ string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, ipc.ErrNoFact
|
||||
}
|
||||
func (r *recordingAPI) LatestFactBySource(_ context.Context, _, _ string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, ipc.ErrNoFact
|
||||
}
|
||||
func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Duration, error) {
|
||||
return 0, ipc.ErrNoFact
|
||||
}
|
||||
func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) {
|
||||
return ipc.Presence{}, nil
|
||||
}
|
||||
func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _ string) (int64, error) {
|
||||
return 1, nil
|
||||
}
|
||||
func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil }
|
||||
func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
|
||||
return 1, nil
|
||||
}
|
||||
func (r *recordingAPI) ResolveNudge(_ context.Context, _ int64, _ string, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *recordingAPI) RecentOutcomes(_ context.Context, _ string, _ int) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *recordingAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *recordingAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *recordingAPI) WriteNote(_ context.Context, _ time.Time, _ string, _ []float32, _ string) (int64, error) {
|
||||
return 1, nil
|
||||
}
|
||||
func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.Note, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *recordingAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) {
|
||||
return ipc.Tool{}, ipc.ErrToolNotFound
|
||||
}
|
||||
func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
|
||||
// matching what ipc.dispatch hands to Server.Check (the raw params frame).
|
||||
func mustWriteFactParams(source string) []byte {
|
||||
b, err := json.Marshal(ipc.WriteFactReq{
|
||||
Kind: "env",
|
||||
Key: "service_down",
|
||||
Value: "down",
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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:
|
||||
// Registration-enable is privilege escalation: it moves the boundary
|
||||
// (adds a runnable capability). Human-only, step-up asserted — never a
|
||||
// module or the voice/chat path. maven can propose but never enable.
|
||||
return AuthStepUp
|
||||
case ipc.MethodWriteFact:
|
||||
return AuthWrite
|
||||
case ipc.MethodLatestFact,
|
||||
ipc.MethodLatestFactBySource,
|
||||
ipc.MethodSince,
|
||||
ipc.MethodPresence,
|
||||
ipc.MethodRecentOutcomes,
|
||||
ipc.MethodCreateReminder,
|
||||
ipc.MethodMarkReminder,
|
||||
ipc.MethodRecordNudge,
|
||||
ipc.MethodResolveNudge:
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// Scope — the resolved authority of a caller. Built by Enrollment *once*
|
||||
// when a connection is accepted (or once per call, depending on impl), then
|
||||
// threaded through Check → Can as pure data. The impure part (looking up the
|
||||
// module enrollment table from ipc.Caller Uid+Pid) ends at the Enrollment
|
||||
// boundary; everything downstream is pure.
|
||||
type Scope struct {
|
||||
// Surface — the channel the caller entered through. Caps the layer.
|
||||
Surface Surface
|
||||
|
||||
// Module — the enrolled module name ("tts", "poll:healthcheck",
|
||||
// "router", "telegram-relay", ""). "" ⇒ unenrolled; Check refuses.
|
||||
// The daemon's in-process path sets "core" by convention.
|
||||
Module string
|
||||
|
||||
// SourceScope — the sources this module may WriteFact under. The spec's
|
||||
// exact "compromised poller can't forge a trigger" guard: a module
|
||||
// only writes sources it owns. The floor enrollment grants "*"
|
||||
// (anything); a real enrollment scopes a poller to one source prefix.
|
||||
// Empty slice ⇒ refuse all writes (fail closed); the daemon never sets
|
||||
// this empty for an enrolled caller.
|
||||
SourceScope []string
|
||||
}
|
||||
|
||||
// Lookup — the Enroller's input: ipc.Caller when present (Uid/Pid from
|
||||
// SO_PEERCRED on the socket), or the zero value for in-process (no Caller
|
||||
// attached to ctx — the daemon treats this as SurfaceCoreProcess, "core").
|
||||
type Lookup struct {
|
||||
// Caller — when CallerFrom(ctx) is absent (the in-process path), this is
|
||||
// the zero ipc.Caller. The Enrollment floor returns SurfaceCoreProcess in
|
||||
// that case.
|
||||
Caller ipc.Caller
|
||||
HasCaller bool
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package auth is maven's authority layer — the 4-layer cascade and the
|
||||
// "surface caps authority" invariant.
|
||||
//
|
||||
// Spec contract (from maven.md § auth):
|
||||
//
|
||||
// a cascade, not a pick-one — each layer answers a different question:
|
||||
//
|
||||
// | layer | question | mechanism | surface |
|
||||
// | 0 | on the network at all? | wireguard | floor |
|
||||
// | 1 | enrolled box? | mTLS client cert | pc client, authed page |
|
||||
// | 2 | you, this session? | passkey / webauthn | pc client, authed page |
|
||||
// | 3 | you, right now, for this act? | passkey user-verification | step-up acts |
|
||||
//
|
||||
// wg is necessary-not-sufficient: an unlocked laptop inside the tunnel is
|
||||
// "authed" at layer 0 only — that gap is why the upper layers exist.
|
||||
//
|
||||
// the invariant: auth tier is a property of the SURFACE; the surface caps
|
||||
// maximum authority. you can't step up past what the channel structurally
|
||||
// carries. voice STOPS at L0 (a room mic is reachable by anyone present →
|
||||
// speaker verification is attribution, not auth). telegram inbound = weak
|
||||
// tier (read + soft acts, never destructive, never registration). only
|
||||
// pc_client / authed_page carry passkey user-verification (L3) at all.
|
||||
//
|
||||
// "compromised X can't forge Y" is the through-line — applied at smaller
|
||||
// and smaller scope (network → box → process). the auth layer applies it at
|
||||
// the process radius: a module calling CoreAPI is bound to a Surface; the
|
||||
// Surface caps what methods/authority that call can carry.
|
||||
package auth
|
||||
|
||||
// Layer — one rung of the auth cascade.
|
||||
type Layer int8
|
||||
|
||||
const (
|
||||
// Layer0 — wireguard: on the network at all. Floor for everything.
|
||||
Layer0 Layer = 0
|
||||
// Layer1 — mTLS client cert: enrolled box. Optional per spec ("if dropping
|
||||
// one, drop mTLS, never the passkey"); not wired by the floor enrollment.
|
||||
Layer1 Layer = 1
|
||||
// Layer2 — passkey/webauthn session: you, this session.
|
||||
Layer2 Layer = 2
|
||||
// Layer3 — passkey user-verification gesture for a SINGLE act: you, right
|
||||
// now, for this. registration-enable, destructive acts, core cold-start
|
||||
// unlock. the highest-authority op.
|
||||
Layer3 Layer = 3
|
||||
)
|
||||
|
||||
// Surface — where a call ENTERS maven from. A property of the channel that
|
||||
// structurally caps the maximum authority that channel can carry. The wire
|
||||
// doesn't carry a layer — it carries a Caller; the Enrollment maps the caller
|
||||
// to a Surface; MaxLayer caps it. So voice can never reach EnableTool — not
|
||||
// because auth "failed" but because the channel can't carry the proof.
|
||||
type Surface string
|
||||
|
||||
const (
|
||||
// SurfaceVoice — a room mic / wake-word path. Speaker verification is
|
||||
// attribution, not auth: anyone present (gf, the TV) can speak. STOPS at
|
||||
// L0. never destructive, never registration.
|
||||
SurfaceVoice Surface = "voice"
|
||||
// SurfaceTelegram — telegram inbound. weak tier: telegram's own auth,
|
||||
// outside our control. read + soft acts, never destructive, never
|
||||
// registration. carries up to L2 (a chat-id allowlist is the best we get).
|
||||
SurfaceTelegram Surface = "telegram"
|
||||
// SurfacePCClient — the desktop gui, mTLS'd and passkey'd. carries L3
|
||||
// (passkey user-verification is available on-device).
|
||||
SurfacePCClient Surface = "pc_client"
|
||||
// SurfaceAuthedPage — the web authed page over wg. carries L3 (passkey
|
||||
// user-verification via the browser / platform authenticator).
|
||||
SurfaceAuthedPage Surface = "authed_page"
|
||||
// SurfaceCoreProcess — a module running in core's own address space (the
|
||||
// daemon-embedded router/delivery today). There is no boundary to cross;
|
||||
// this is the 0600-floor equivalent: trusted same-process. Layer3-capped
|
||||
// since the user already unlocked the daemon (cold-start IS L3 per spec).
|
||||
SurfaceCoreProcess Surface = "core_process"
|
||||
// SurfaceUnknown — enrollment didn't recognize the caller. fail closed.
|
||||
SurfaceUnknown Surface = "unknown"
|
||||
)
|
||||
|
||||
// MaxLayer — the surface-caps-authority table. PURE. The invariant: you
|
||||
// cannot step up past what your channel structurally carries. voice → L0;
|
||||
// telegram → L2 (chat allowlist); pc_client / authed_page / core_process →
|
||||
// L3. An unrecognized surface caps at -1 (fail closed) — a caller with no
|
||||
// enrolled identity is not "unauthed at L0", it's refused outright.
|
||||
func MaxLayer(s Surface) Layer {
|
||||
switch s {
|
||||
case SurfaceVoice:
|
||||
return Layer0
|
||||
case SurfaceTelegram:
|
||||
return Layer2
|
||||
case SurfacePCClient, SurfaceAuthedPage, SurfaceCoreProcess:
|
||||
return Layer3
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user