Files
Maven/internal/auth/auth_test.go
T
kami ad074cea31 Swap the resident model without restarting mavend (#250)
Loading a different gguf was a one-line edit to phraser.model_path plus a
restart. It is now an owner-triggered IPC call, off unless configured.

internal/phraser/swap.go holds the safety properties as code:

  - Never two models resident. The old llama-server is killed and reaped
    before the new one is launched. One 1.7B fits the Vega iGPU; a
    blue/green overlap would OOM the box, so it is not offered.
  - Atomic from a turn's point of view. Swap drains the in-flight turns
    (they finish on the old model), then refuses arrivals with ErrSwapping
    until the new server has answered /v1/models. No turn ever sees half a
    swap; refused turns fall back to the classifier cascade.
  - A failed load rolls back. If the new model does not start or does not
    probe, the previous one is reloaded and the call returns RolledBack
    with the error. If the rollback also fails the daemon says so and
    degrades to the classifier rather than pretending to serve.

Holders of the completion client are re-pointed, not rebuilt: llm.Client
guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the
replier, the mail extractor and the memory evaluator follow the new port
without knowing a swap happened.

Reach is deliberately narrow. phraser.swap_models is an exact-match
allowlist of absolute paths a human wrote, rejected at startup otherwise,
so "swap the model" can never mean "load any file on my disk"; the running
model is always swappable back to. MethodSwapModel is AuthStepUp, the same
rung as mutating the tool allowlist, and /models gates POST through the
same stepUpOK the tools page uses. Nothing calls Swap on a timer and no
act, intent or utterance reaches it.

Vikunja #250
2026-08-01 03:59:08 +04:00

419 lines
15 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"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. Embeds ipc.UnimplementedCoreAPI so every method
// this test doesn't exercise returns ipc.ErrNotImplemented loudly instead of
// being hand-stubbed to a canned value nobody checks.
type recordingAPI struct {
ipc.UnimplementedCoreAPI
writes int
chats int
}
func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) {
r.writes++
return int64(r.writes), 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
}
// TestRequirement_SwapModel — loading a different resident model is an owner
// action at the same rung as mutating the tool allowlist: it decides how every
// utterance is routed and how every reply is worded. The read side is not.
func TestRequirement_SwapModel(t *testing.T) {
if got := Requirement(ipc.MethodSwapModel); got != AuthStepUp {
t.Errorf("SwapModel authority = %v; want AuthStepUp", got)
}
if got := Requirement(ipc.MethodModelStatus); got != AuthRead {
t.Errorf("ModelStatus authority = %v; want AuthRead", got)
}
// A surface that cannot carry a passkey gesture cannot swap the model, no
// matter what it is enrolled as — this is the "never through voice" property.
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
if err := Can(ipc.MethodSwapModel, voice, nil); !errors.Is(err, ErrForbidden) {
t.Errorf("voice swapping the model = %v; want ErrForbidden", err)
}
// And with no step-up session asserted, the gate refuses even a capable surface.
noSession := &Gate{Enrollment: NewFloorEnrollment()}
if err := noSession.Check(context.Background(), ipc.MethodSwapModel, nil); !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("SwapModel with no asserted step-up = %v; want ErrForbidden", err)
}
}