5fe8f228c1
Add a read-only /ecosystem page that consumes the sibling services' JSON APIs (Nexus entities, Praxis attention, Hexis capabilities), fetched concurrently with honest per-panel error states. Siblings stay headless — mavweb is their human surface (arch §16). Wired via mavweb -nexus/-praxis/-hexis flags; mavweb joins the ecosystem compose network. Fix mobile horizontal overflow across all pages: .content is a flex child with default min-width:auto, so it refused to shrink below the tables' intrinsic width. min-width:0 lets wide tables pan inside .scroll instead of dragging the page sideways. Verified via CDP geometry check (scrollWidth === clientWidth at 430px). Also includes in-progress Ethos UI redesign, ecosystem deploy compose, and planning docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
469 lines
16 KiB
Go
469 lines
16 KiB
Go
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,
|
|
ipc.MethodChat,
|
|
}
|
|
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,
|
|
ipc.MethodChat,
|
|
} {
|
|
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)
|
|
}
|
|
_, err = cli.Chat(context.Background(), "привет")
|
|
if !errors.Is(err, ipc.ErrForbidden) {
|
|
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
|
}
|
|
if fake.chats != 0 {
|
|
t.Errorf("auth refused chat but CoreAPI.Chat was called %d time(s)", fake.chats)
|
|
}
|
|
}
|
|
|
|
func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
|
gate := &Gate{Enrollment: NewFloorEnrollment()}
|
|
fake := &recordingAPI{}
|
|
sock := filepath.Join(t.TempDir(), "maven.sock")
|
|
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
|
|
})
|
|
|
|
cli, err := ipc.Dial(sock)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cli.Close() })
|
|
reply, err := cli.Chat(context.Background(), "привет")
|
|
if err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if reply != "echo: привет" {
|
|
t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет")
|
|
}
|
|
if fake.chats != 1 {
|
|
t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
chats 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) ListReminders(_ context.Context, _ int) ([]ipc.Reminder, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
|
return ipc.TickTrace{}, 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) CalendarEvents(_ context.Context, _, _ time.Time) ([]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, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) DeleteTool(_ context.Context, _ string) 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
|
|
}
|
|
|
|
func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
|
return nil, nil
|
|
}
|
|
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
|
return nil
|
|
}
|
|
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
|
r.chats++
|
|
return "echo: " + text, 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
|
|
}
|