543aefde4b
Saving a description writes recall corpus. writeNote embeds it under media:image:<id>, a source no enrollment owns, and the method sits at AuthRead, so any enrolled module could put a small VLM's guess into what Maven knows and have it come back in a later turn as something she believes. The describing half stays a read; save_note is now held to the same source-scope rule WriteFact is, and the stored text carries a marker saying it came off a picture. Three doc comments said the method exists only when vision is enabled and the code says otherwise. The code is right, and storing without describing is the state this box is in, so the comments were corrected rather than the behaviour. A request carrying both data and id used to take the id branch and drop the bytes without a word; it is refused. A media dir that cannot be created and a vision endpoint that is a typo were logged at wiring time and the capability just stayed off, which is the hardest kind of misconfiguration to notice. Both fail at startup. runPrune was the one loop started with a bare go and not in the daemon's WaitGroup, so shutdown did not wait for a prune that was deleting files. Found in review of #72.
502 lines
19 KiB
Go
502 lines
19 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|