7d4b4ad736
The clarify store had one key for the whole daemon, so a question asked in the web chat and never answered captured the next three utterances from any source — telegram, or the mic — and answered them against a request the speaker never made. The reach now supplies a conversation id on the IPC Chat call, and the daemon carries it on the context the way it already carries the correlation id, so the six clarify call sites read it instead of a constant. The mic has no id of its own and keeps the key it had, so voice behaves exactly as before. mavweb has no per-browser session, so every tab is one conversation: right for a single-owner box, and still distinct from telegram and the mic. Dialogue sessions stay global on purpose — they are what she remembers about him, not what she is waiting for from one channel.
524 lines
20 KiB
Go
524 lines
20 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(), "web", "привет")
|
|
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(), "web", "привет")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestRequirement_ListMutation — the three methods that change what is on his
|
|
// lists sit on one rung. IngestMail joined them on 2026-08-01; it used to be
|
|
// AuthRead, which made it disagree with SetTaskStatus about the same question.
|
|
// CaptureTask stays a read: it puts one line on a list he asked for.
|
|
func TestRequirement_ListMutation(t *testing.T) {
|
|
for _, m := range []ipc.Method{
|
|
ipc.MethodIngestMail, ipc.MethodSetTaskStatus,
|
|
} {
|
|
if got := Requirement(m); got != AuthWrite {
|
|
t.Errorf("%s authority = %v; want AuthWrite", m, got)
|
|
}
|
|
}
|
|
if got := Requirement(ipc.MethodCaptureTask); got != AuthRead {
|
|
t.Errorf("CaptureTask authority = %v; want AuthRead", got)
|
|
}
|
|
// mavmaild keeps working: AuthWrite outside WriteFact only needs enrollment.
|
|
maild := Scope{Surface: SurfaceCoreProcess, Module: "mavmaild", SourceScope: []string{"mail:*"}}
|
|
if err := Can(ipc.MethodIngestMail, maild, nil); err != nil {
|
|
t.Errorf("mavmaild ingesting mail = %v; want allowed", err)
|
|
}
|
|
}
|
|
|
|
// TestRequirement_Capture — recording other people is a write, not a read: it
|
|
// puts audio of them on disk. The read side, "что ты записываешь?", is not.
|
|
//
|
|
// It is deliberately NOT AuthStepUp. Step-up needs a passkey gesture, which the
|
|
// voice path cannot make, so putting it there would mean "запиши встречу" could
|
|
// never work by voice. The real gate on this capability is that the methods do
|
|
// not exist at all unless the operator enabled a capture block.
|
|
func TestRequirement_Capture(t *testing.T) {
|
|
for _, m := range []ipc.Method{
|
|
ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop,
|
|
} {
|
|
if got := Requirement(m); got != AuthWrite {
|
|
t.Errorf("%s authority = %v; want AuthWrite", m, got)
|
|
}
|
|
}
|
|
if got := Requirement(ipc.MethodCaptureStatus); got != AuthRead {
|
|
t.Errorf("CaptureStatus authority = %v; want AuthRead", got)
|
|
}
|
|
// Voice can start one: it is the surface he will actually use to say
|
|
// "запиши встречу", and it carries AuthWrite.
|
|
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
|
if err := Can(ipc.MethodCaptureStart, voice, nil); err != nil {
|
|
t.Errorf("voice starting a capture = %v; want allowed", err)
|
|
}
|
|
}
|
|
|
|
// TestRequirement_Speaker — a voiceprint is a biometric of a named person, so
|
|
// taking one is step-up: a deliberate act from a surface that can carry a
|
|
// passkey gesture, never something the voice path does mid-conversation.
|
|
//
|
|
// Deletion is one rung lower, and that asymmetry is the point. Everywhere else
|
|
// in the table the destructive direction is gated at least as hard as the
|
|
// constructive one; for a biometric that would be backwards, because getting
|
|
// rid of it must never be the harder half.
|
|
func TestRequirement_Speaker(t *testing.T) {
|
|
if got := Requirement(ipc.MethodEnrollSpeaker); got != AuthStepUp {
|
|
t.Errorf("EnrollSpeaker authority = %v; want AuthStepUp", got)
|
|
}
|
|
if got := Requirement(ipc.MethodForgetSpeaker); got != AuthWrite {
|
|
t.Errorf("ForgetSpeaker authority = %v; want AuthWrite", got)
|
|
}
|
|
if got := Requirement(ipc.MethodListSpeakers); got != AuthRead {
|
|
t.Errorf("ListSpeakers authority = %v; want AuthRead", got)
|
|
}
|
|
// Voice cannot enrol anybody, however the utterance is phrased.
|
|
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
|
if err := Can(ipc.MethodEnrollSpeaker, voice, nil); err == nil {
|
|
t.Error("voice enrolling a speaker was allowed; want refused")
|
|
}
|
|
// But it can read the roster, which is what answering "кого ты знаешь?"
|
|
// needs.
|
|
if err := Can(ipc.MethodListSpeakers, voice, nil); err != nil {
|
|
t.Errorf("voice listing speakers = %v; want allowed", err)
|
|
}
|
|
}
|
|
|
|
// Describing an image is a read. Saving the description is a write of recall
|
|
// corpus under a source no enrollment owns, so it is held to the same
|
|
// source-scope rule WriteFact is. Before this, any AuthRead caller could put a
|
|
// small VLM's guess into what Maven knows.
|
|
func TestCan_DescribeImage_SaveNoteNeedsScope(t *testing.T) {
|
|
poller := Scope{Surface: SurfaceTelegram, Module: "poll", SourceScope: []string{"poll:healthcheck"}}
|
|
web := Scope{Surface: SurfaceAuthedPage, Module: "web", SourceScope: []string{"*"}}
|
|
|
|
plain, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
noting, err := json.Marshal(ipc.DescribeImageReq{Data: []byte("x"), SaveNote: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Can(ipc.MethodDescribeImage, poller, plain); err != nil {
|
|
t.Errorf("describing without saving must stay a read: %v", err)
|
|
}
|
|
if err := Can(ipc.MethodDescribeImage, poller, noting); !errors.Is(err, ErrForbidden) {
|
|
t.Errorf("save_note out of scope = %v, want ErrForbidden", err)
|
|
}
|
|
if err := Can(ipc.MethodDescribeImage, web, noting); err != nil {
|
|
t.Errorf("a module scoped to everything must still be allowed: %v", err)
|
|
}
|
|
}
|