From 0067f3bfe3cf4a2601fbdc4c4deb154861f28998 Mon Sep 17 00:00:00 2001 From: agentydragon-agent <260791330+agentydragon-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:10:49 -0800 Subject: [PATCH 1/2] Admit service SVID readers for Notification diagnostics --- agentplane/docs/system_overview.md | 6 ++- agentplane/notification_service/README.md | 10 ++-- agentplane/notification_service/config.go | 27 +++++++---- .../notification_service/config_test.go | 16 +++++++ agentplane/notification_service/docs/api.md | 13 ++--- .../internal/rpc/fixture_test.go | 48 +++++++++++++++++-- .../internal/rpc/methods_test.go | 5 +- .../internal/rpc/notifications.go | 7 ++- .../internal/rpc/operator_test.go | 41 ++++++++++++++++ .../internal/rpc/ownership.go | 6 ++- .../internal/rpc/service.go | 17 ++++++- agentplane/notification_service/main.go | 11 +++-- agentplane/notification_service/settings.py | 17 ++++++- .../notification_service/test_settings.py | 20 ++++++++ 14 files changed, 211 insertions(+), 33 deletions(-) diff --git a/agentplane/docs/system_overview.md b/agentplane/docs/system_overview.md index 98abf5cfe0c..529bae8abf3 100644 --- a/agentplane/docs/system_overview.md +++ b/agentplane/docs/system_overview.md @@ -551,7 +551,7 @@ flowchart LR agent -.->|HTTP subscriptions, inboxes, messages| sidecar sidecar -.->|Sandbox SVID to destination inside CONNECT| proxy proxy -.->|opaque TLS| notifications - app -->|gRPC operator watches + health| notifications + app -->|gRPC operator watches + health; optional exact service-SVID reader| notifications github --> notifications notifications --> github notifications -->|Action history: gRPC| actions @@ -565,7 +565,9 @@ Sandboxes keep their existing HTTP service URL. Their sidecar or relay recognize opens CONNECT through the central proxy, and presents the Runner SVID to Notification's shared TLS RPC listener. Notification resolves the current Sandbox and its ServiceAccount; inbox ownership and send grants are unchanged. Backend and operator callers retain their method-specific bearer -authorization on that listener. +authorization on that listener. Independently configured service SVIDs may read Session diagnostics +through the read-only operator methods; that grant never supplies an agent Identity or permits +inbox mutation. Deployment settings and callers retain their current authentication until cutover. The Notification service does not call the integration app, which watches subscription and notice state over the operator-only `NotificationsOperator` gRPC service. GitHub sends it signed webhooks; it diff --git a/agentplane/notification_service/README.md b/agentplane/notification_service/README.md index f41cd677a92..31397069968 100644 --- a/agentplane/notification_service/README.md +++ b/agentplane/notification_service/README.md @@ -59,8 +59,9 @@ Sandbox callers authenticate with the Runner SVID on the shared TLS RPC listener Egress routes workload gRPC through an opaque central-proxy CONNECT. Notification verifies the certificate chain, explicit clientAuth EKU, expiry, current Sandbox UID and accepted credential ID, then resolves the credential's exact live Identity name and UID. Caller headers cannot select another Identity. Native gRPC uses this resolver, -and watch revalidation repeats the live lookup. Backend/operator callers use the same listener -with their audience-scoped tokens and existing authorization. +and watch revalidation repeats the live lookup. Backend and legacy operator callers use the same listener +with their audience-scoped tokens and existing authorization. An exact configured service SVID may call +only the namespace diagnostic read methods; it does not become an agent Identity or own an inbox. ## Running and deployment @@ -130,7 +131,10 @@ that fails validation stops startup without printing its value. The native gRPC listener (`rpc.listen_address`) serves `Notifications` and `grpc.health.v1`. `rpc.tls` supplies its server certificate and key, and `rpc.auth` explicitly admits SPIFFE and/or Kubernetes bearer authentication. Both modes retain the same method and inbox ownership policy. -A certificate combined with bearer or delegation metadata is refused. +A certificate combined with bearer or delegation metadata is refused. `operator_reader_services` +admits exact service SPIFFE IDs to the five diagnostic read methods; it requires SPIFFE client trust +and can coexist with `operator_reader_account` during a rollout. A service SVID grants neither an +agent Identity nor inbox ownership, and is not admitted to mutations or other RPCs. The HTTP listener (`host`, `http_port`) serves only `POST /v1/webhooks/github` when GitHub is configured; all other paths return not found. It has no workload HTTP API or documentation routes. The generated App browser facade dials the gRPC listener, and the CLI embeds the workflow guides. Health checks take no bearer: service `""` is readiness (the LISTEN diff --git a/agentplane/notification_service/config.go b/agentplane/notification_service/config.go index 3aa0509fb3e..046ae177fe7 100644 --- a/agentplane/notification_service/config.go +++ b/agentplane/notification_service/config.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/agentydragon/ducktape/agentplane/identity/auth" "github.com/agentydragon/ducktape/agentplane/identity/auth/listener" "github.com/agentydragon/ducktape/agentplane/net/peertls" "github.com/agentydragon/ducktape/agentplane/notification_service/internal/sources/github/settings" @@ -36,14 +37,16 @@ type config struct { Namespace string `mapstructure:"namespace"` TokenAudience string `mapstructure:"token_audience"` // OperatorReaderAccount is the ServiceAccount allowed to inspect Session diagnostics across the namespace. - OperatorReaderAccount string `mapstructure:"operator_reader_account"` - Quotas store.Quotas `mapstructure:"quotas"` - worker.Settings `mapstructure:",squash"` - GitHubRetention settings.Retention `mapstructure:",squash"` - Actions actionPeer `mapstructure:"actions"` - RunnerGateway runnerGateway `mapstructure:"runner_gateway"` - HistoryService peer `mapstructure:"history_service"` - SessionDispatcher peer `mapstructure:"session_dispatcher"` + OperatorReaderAccount string `mapstructure:"operator_reader_account"` + // OperatorReaderServices are exact service SVIDs admitted to the same read-only diagnostic methods. + OperatorReaderServices []string `mapstructure:"operator_reader_services"` + Quotas store.Quotas `mapstructure:"quotas"` + worker.Settings `mapstructure:",squash"` + GitHubRetention settings.Retention `mapstructure:",squash"` + Actions actionPeer `mapstructure:"actions"` + RunnerGateway runnerGateway `mapstructure:"runner_gateway"` + HistoryService peer `mapstructure:"history_service"` + SessionDispatcher peer `mapstructure:"session_dispatcher"` // GitHub is on when any of its settings is set. GitHub settings.Settings `mapstructure:"github"` // RequestTimeoutSeconds bounds a call's backend work and each write to a watch stream. @@ -230,6 +233,14 @@ func (c *config) validate() error { if err := c.RPC.Validate(); err != nil { return fmt.Errorf("rpc listener: %w", err) } + if err := auth.ValidateServiceCallers(c.OperatorReaderServices); err != nil { + return fmt.Errorf("invalid operator_reader_services: %w", err) + } + if len(c.OperatorReaderServices) > 0 { + if !c.RPC.Has(listener.SPIFFE) { + return errors.New("operator_reader_services requires rpc spiffe client trust") + } + } if !c.RPC.AllowsBearerAudience(c.TokenAudience) { return errors.New("rpc.auth must allow the configured token_audience") } diff --git a/agentplane/notification_service/config_test.go b/agentplane/notification_service/config_test.go index 79234554574..57ac447ab2d 100644 --- a/agentplane/notification_service/config_test.go +++ b/agentplane/notification_service/config_test.go @@ -122,6 +122,22 @@ func TestTheSettingsTheDeploymentManifestsSetLoadWithTheirSecretsFromTheEnvironm } } +func TestOperatorReaderAccountAndServiceSVIDCanCoexist(t *testing.T) { + file := strings.Replace( + deploymentFile, + "operator_reader_account: test-app\n", + "operator_reader_account: test-app\noperator_reader_services: [spiffe://agentplane/app]\n", + 1, + ) + c, err := loadTestConfig(t, file, webhookSecretEnv, privateKeyEnv) + if err != nil { + t.Fatal(err) + } + if c.OperatorReaderAccount != "test-app" || len(c.OperatorReaderServices) != 1 || c.OperatorReaderServices[0] != "spiffe://agentplane/app" { + t.Fatalf("operator readers were not retained together: account=%q services=%v", c.OperatorReaderAccount, c.OperatorReaderServices) + } +} + func TestWhatADeploymentLeavesUnsetTakesItsDefault(t *testing.T) { c, err := loadTestConfig(t, requiredFile) if err != nil { diff --git a/agentplane/notification_service/docs/api.md b/agentplane/notification_service/docs/api.md index 6ea6a33273a..b712dc81c8f 100644 --- a/agentplane/notification_service/docs/api.md +++ b/agentplane/notification_service/docs/api.md @@ -47,9 +47,9 @@ for the exact original Sandbox UID. Unknown or inconsistent owner identity pause or idempotently cancel. Cancelling never cancels an Action or erases accepted entries. - `GET /v1/inboxes`: the Identity's qualified session inboxes. - `GET /v1/sessions/{session_id}/inbox/entries?after_cursor=0&limit=128`: non-destructive, contiguous paging, prefix - metadata, and the latest notice's admission/confirmation state. The configured app account may inspect - retained entries by Session ID through its authenticated operator facade, including after the original - Sandbox disappears. It cannot acknowledge or retire them. Payloads are stored provider event snapshots, + metadata, and the latest notice's admission/confirmation state. The configured operator account and exact + service SVID readers may inspect retained entries by Session ID, including after the original Sandbox + disappears. Diagnostic readers cannot acknowledge or retire another caller's inbox. Payloads are stored provider event snapshots, not fetched at read time or promoted to operator instructions. - `PUT /v1/sessions/{session_id}/inbox/acknowledgement` with `through_cursor`: monotonically acknowledge the handled prefix. Future cursors are refused. Delivery and reads never acknowledge. @@ -303,9 +303,10 @@ lifecycle fields; it never returns provider payloads. `WatchSubscriptions` repor state. Each watch starts with current state; entry and subscription watches then send changes. A missing inbox is a state, since inboxes are created lazily. Reconnecting starts from fresh state. Watches send heartbeats and revalidate authorization; they do not end on a fixed lease. Reading -never acknowledges entries. Workloads can read their own Sandbox incarnation's inbox; the -configured `operator_reader_account` can inspect Session diagnostics in the namespace but cannot -read full inbox payloads or mutate another account's inbox. +never acknowledges entries. Workloads can read their own Sandbox incarnation's inbox; the configured +`operator_reader_account` and exact `operator_reader_services` SVIDs can inspect Session diagnostics +in the namespace. Service SVID admission is restricted to these read-only methods and never asserts +an agent Identity. Diagnostic readers cannot mutate another caller's inbox. A Session has at most one inbox. The database migration refuses existing collisions rather than choosing or discarding records; reconcile any collisions before admitting writes with the new schema. diff --git a/agentplane/notification_service/internal/rpc/fixture_test.go b/agentplane/notification_service/internal/rpc/fixture_test.go index c2b5f78333d..ea9e02860ef 100644 --- a/agentplane/notification_service/internal/rpc/fixture_test.go +++ b/agentplane/notification_service/internal/rpc/fixture_test.go @@ -298,7 +298,8 @@ type serverConfig struct { debounce store.Debounce githubDisabled bool // operatorReader is the account allowed to inspect namespace Session diagnostics. - operatorReader string + operatorReader string + operatorReaderServices []string // messageBodyLimit is in UTF-8 bytes. messageBodyLimit int } @@ -306,6 +307,7 @@ type serverConfig struct { type testServer struct { notifications *Notifications client notificationspb.NotificationsClient + serviceClient notificationspb.NotificationsClient clientRoutes *routedClientConn store *store.Store hub *wakeups.Hub @@ -321,6 +323,7 @@ type testServer struct { workload *workloadauthtesting.IdentityWorkload ca *transporttesting.CA identityAddress string + serviceAddress string // kube holds the Sandboxes the service reads. kube client.Client config serverConfig @@ -442,7 +445,8 @@ func newServer(t *testing.T, configure ...func(*serverConfig)) testServer { options := Options{ Store: server.store, Wakeups: hub, Namespace: testNamespace, Sandboxes: sandboxes, Runner: server.runner, Actions: server.actions, OperatorReader: config.operatorReader, - SendPolicy: server.policy, History: server.history, Sessions: server.openings, MessageBodyLimit: config.messageBodyLimit, + OperatorReaderServices: config.operatorReaderServices, + SendPolicy: server.policy, History: server.history, Sessions: server.openings, MessageBodyLimit: config.messageBodyLimit, Debounce: config.debounce, RequestTimeout: config.requestTimeout, } if !config.githubDisabled { @@ -453,7 +457,11 @@ func newServer(t *testing.T, configure ...func(*serverConfig)) testServer { if err != nil { t.Fatal(err) } - grpcServer := grpc.NewServer(Methods(options, resolver).ServerOptions(config.requestTimeout)...) + methods, err := Methods(options, resolver) + if err != nil { + t.Fatal(err) + } + grpcServer := grpc.NewServer(methods.ServerOptions(config.requestTimeout)...) notificationspb.RegisterNotificationsServer(grpcServer, server.notifications) go func() { _ = grpcServer.Serve(listener) }() t.Cleanup(grpcServer.Stop) @@ -483,16 +491,48 @@ func newServer(t *testing.T, configure ...func(*serverConfig)) testServer { if err != nil { t.Fatal(err) } - identityGRPCServer := grpc.NewServer(append(Methods(options, identityResolver).ServerOptions(config.requestTimeout), grpc.Creds(credentials.NewTLS(serverTLS)))...) + identityMethods, err := Methods(options, identityResolver) + if err != nil { + t.Fatal(err) + } + identityGRPCServer := grpc.NewServer(append(identityMethods.ServerOptions(config.requestTimeout), grpc.Creds(credentials.NewTLS(serverTLS)))...) notificationspb.RegisterNotificationsServer(identityGRPCServer, server.notifications) go func() { _ = identityGRPCServer.Serve(identityListener) }() t.Cleanup(identityGRPCServer.Stop) identityConn := connectIdentityClient(t, server.ca, identityWorkload.Workload, identityListener.Addr().String()) server.identityAddress = identityListener.Addr().String() server.clientRoutes = &routedClientConn{account: connection, identity: identityConn} + var serviceConn *grpc.ClientConn + if len(config.operatorReaderServices) > 0 { + serviceTLS, err := serving.WorkloadServerTLSConfigWithServices(peertls.NotificationsURI, true) + if err != nil { + t.Fatal(err) + } + serviceListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server.serviceAddress = serviceListener.Addr().String() + serviceServer := grpc.NewServer(append(methods.ServerOptions(config.requestTimeout), grpc.Creds(credentials.NewTLS(serviceTLS)))...) + notificationspb.RegisterNotificationsServer(serviceServer, server.notifications) + go func() { _ = serviceServer.Serve(serviceListener) }() + t.Cleanup(serviceServer.Stop) + serviceCredentials, err := server.ca.Issue(t, peertls.AppURI).ClientCredentials(peertls.NotificationsURI) + if err != nil { + t.Fatal(err) + } + serviceConn, err = grpc.NewClient(serviceListener.Addr().String(), grpc.WithTransportCredentials(serviceCredentials)) + if err != nil { + t.Fatal(err) + } + server.serviceClient = notificationspb.NewNotificationsClient(serviceConn) + } t.Cleanup(func() { _ = connection.Close() _ = identityConn.Close() + if serviceConn != nil { + _ = serviceConn.Close() + } }) server.client = notificationspb.NewNotificationsClient(server.clientRoutes) return server diff --git a/agentplane/notification_service/internal/rpc/methods_test.go b/agentplane/notification_service/internal/rpc/methods_test.go index ca4e1029f5c..65d81d40347 100644 --- a/agentplane/notification_service/internal/rpc/methods_test.go +++ b/agentplane/notification_service/internal/rpc/methods_test.go @@ -36,7 +36,10 @@ func TestEveryRPCAdmitsOnlyItsCallers(t *testing.T) { {"Notifications", operatorName, notificationspb.Notifications_ServiceDesc, workloads}, } { t.Run(test.name, func(t *testing.T) { - methods := Methods(Options{Namespace: testNamespace, OperatorReader: test.reader}, resolver) + methods, err := Methods(Options{Namespace: testNamespace, OperatorReader: test.reader}, resolver) + if err != nil { + t.Fatal(err) + } connection := grpcmethods.Serve(t, methods, registerStandIns) for _, method := range grpcmethods.Of(test.desc) { t.Run(method.Name, func(t *testing.T) { diff --git a/agentplane/notification_service/internal/rpc/notifications.go b/agentplane/notification_service/internal/rpc/notifications.go index ba5e63a61be..08f27a99af1 100644 --- a/agentplane/notification_service/internal/rpc/notifications.go +++ b/agentplane/notification_service/internal/rpc/notifications.go @@ -36,6 +36,7 @@ type Notifications struct { timeout time.Duration wakeups *wakeups.Hub operatorReader string + operatorServices map[string]struct{} debounce store.Debounce draining context.Context drain context.CancelFunc @@ -43,11 +44,15 @@ type Notifications struct { func NewNotifications(options Options) *Notifications { draining, drain := context.WithCancel(context.Background()) + operatorServices := make(map[string]struct{}, len(options.OperatorReaderServices)) + for _, service := range options.OperatorReaderServices { + operatorServices[service] = struct{}{} + } return &Notifications{ store: options.Store, namespace: options.Namespace, sandboxes: options.Sandboxes, runner: options.Runner, actions: options.Actions, github: options.GitHub, policy: options.SendPolicy, history: options.History, sessions: options.Sessions, messageBodyLimit: options.MessageBodyLimit, timeout: options.RequestTimeout, - wakeups: options.Wakeups, operatorReader: options.OperatorReader, debounce: options.Debounce, + wakeups: options.Wakeups, operatorReader: options.OperatorReader, operatorServices: operatorServices, debounce: options.Debounce, draining: draining, drain: drain, } } diff --git a/agentplane/notification_service/internal/rpc/operator_test.go b/agentplane/notification_service/internal/rpc/operator_test.go index 3c09fa53171..b9a24c50da5 100644 --- a/agentplane/notification_service/internal/rpc/operator_test.go +++ b/agentplane/notification_service/internal/rpc/operator_test.go @@ -5,8 +5,11 @@ import ( "strings" "testing" + "github.com/agentydragon/ducktape/agentplane/net/peertls" pb "github.com/agentydragon/ducktape/agentplane/notification_service/api/notifications/notificationspb" + "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func TestOperatorInspectionDoesNotGrantInboxMutation(t *testing.T) { @@ -61,6 +64,44 @@ func TestOperatorPayloadReadRequiresConfiguredAccount(t *testing.T) { requireCode(t, err, codes.NotFound) } +func TestAppServiceSVIDCanReadDiagnosticsWithoutBecomingAnIdentity(t *testing.T) { + s := newServer(t, func(config *serverConfig) { + config.operatorReaderServices = []string{peertls.AppURI} + }) + subscription := s.subscribe(t, "app-svid-reader") + s.record(t, subscription, 1) + + page, err := s.serviceClient.ReadInbox(bearing(t, ""), &pb.ReadInboxRequest{SessionId: testSession}) + if err != nil || len(page.GetEntries()) != 1 || page.GetInbox().GetAcknowledged() != 0 { + t.Fatalf("service SVID read = %v, %v", page, err) + } + _, err = s.serviceClient.AcknowledgeInbox(bearing(t, ""), &pb.AcknowledgeInboxRequest{ + SessionId: testSession, ThroughCursor: 1, + }) + requireCode(t, err, codes.PermissionDenied) + _, err = s.serviceClient.ListInboxes(bearing(t, ""), &pb.ListInboxesRequest{}) + requireCode(t, err, codes.PermissionDenied) + + legacyPage, err := s.client.ReadInbox(bearing(t, operatorToken), &pb.ReadInboxRequest{SessionId: testSession}) + if err != nil || len(legacyPage.GetEntries()) != 1 { + t.Fatalf("legacy account reader stopped working during overlap = %v, %v", legacyPage, err) + } + + wrongCredentials, err := s.ca.Issue(t, peertls.ActionsURI).ClientCredentials(peertls.NotificationsURI) + if err != nil { + t.Fatal(err) + } + wrongConnection, err := grpc.NewClient(s.serviceAddress, grpc.WithTransportCredentials(wrongCredentials)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = wrongConnection.Close() }) + _, err = pb.NewNotificationsClient(wrongConnection).ReadInbox(bearing(t, ""), &pb.ReadInboxRequest{SessionId: testSession}) + if got := status.Code(err); got != codes.PermissionDenied { + t.Fatalf("unallowlisted service SVID read status = %v, want %v (%v)", got, codes.PermissionDenied, err) + } +} + func TestSessionInboxReadsAreAuthorizedAndDoNotAcknowledge(t *testing.T) { s := newServer(t) sub := s.subscribe(t, "session-status") diff --git a/agentplane/notification_service/internal/rpc/ownership.go b/agentplane/notification_service/internal/rpc/ownership.go index 95c1382bec2..81871fc8e41 100644 --- a/agentplane/notification_service/internal/rpc/ownership.go +++ b/agentplane/notification_service/internal/rpc/ownership.go @@ -48,7 +48,7 @@ func (s *Notifications) currentWorkload(ctx context.Context) (workload, error) { } } -// operator reports whether the admitted caller is the configured operator reader, a ServiceAccount. +// operator reports whether the admitted caller is a configured ServiceAccount or service-SVID diagnostic reader. func (s *Notifications) operator(ctx context.Context) (bool, error) { admission, err := auth.Admitted(ctx) if err != nil { @@ -57,6 +57,10 @@ func (s *Notifications) operator(ctx context.Context) (bool, error) { if _, ok := admission.(auth.IdentityAdmission); ok { return false, nil } + if service, ok := admission.(auth.ServiceAdmission); ok { + _, allowed := s.operatorServices[service.Caller.SPIFFEID] + return allowed, nil + } caller, err := s.caller(ctx) if err != nil { return false, err diff --git a/agentplane/notification_service/internal/rpc/service.go b/agentplane/notification_service/internal/rpc/service.go index f5709a6954f..f32af204335 100644 --- a/agentplane/notification_service/internal/rpc/service.go +++ b/agentplane/notification_service/internal/rpc/service.go @@ -74,6 +74,8 @@ type Options struct { MessageBodyLimit int // OperatorReader is the name, in Namespace, of the ServiceAccount that can read Session diagnostics for the namespace. OperatorReader string + // OperatorReaderServices are exact component SVIDs allowed to read Session diagnostics, independently of agent Identity admission. + OperatorReaderServices []string // Debounce is what the worker waits before it announces entries; the operator's reads report it. Debounce store.Debounce // RequestTimeout bounds a call's backend work and each write to a watch stream. @@ -84,9 +86,9 @@ type Options struct { // enforce resource ownership. Passing the listener's source keeps TLS SVID and TokenReview methods on the same // method policy without coupling either resolver to the servicers' options. A workload's own inboxes and // subscriptions, subscribing and messaging also admit an Identity caller (AnySubject). -func Methods(options Options, resolver *auth.Resolver) auth.Methods { +func Methods(options Options, resolver *auth.Resolver) (auth.Methods, error) { owners := auth.AnySubject(resolver) - return auth.Methods{ + methods := auth.Methods{ notificationspb.Notifications_CreateSubscription_FullMethodName: owners, notificationspb.Notifications_ListSubscriptions_FullMethodName: owners, notificationspb.Notifications_GetSubscription_FullMethodName: owners, @@ -103,4 +105,15 @@ func Methods(options Options, resolver *auth.Resolver) auth.Methods { notificationspb.Notifications_SendMessage_FullMethodName: owners, notificationspb.Notifications_GetMessage_FullMethodName: owners, } + return methods.WithServiceCallers(operatorReaderMethods(), options.OperatorReaderServices) +} + +func operatorReaderMethods() []string { + return []string{ + notificationspb.Notifications_GetInbox_FullMethodName, + notificationspb.Notifications_WatchInbox_FullMethodName, + notificationspb.Notifications_WatchInboxEntries_FullMethodName, + notificationspb.Notifications_WatchSubscriptions_FullMethodName, + notificationspb.Notifications_ReadInbox_FullMethodName, + } } diff --git a/agentplane/notification_service/main.go b/agentplane/notification_service/main.go index 69bdc3b980f..7fb1fadf4bd 100644 --- a/agentplane/notification_service/main.go +++ b/agentplane/notification_service/main.go @@ -250,14 +250,19 @@ func serve(ctx context.Context, c config, deps kubeDeps, grpcListener, httpListe Actions: actionsSource, GitHub: githubAPI, SendPolicy: sendPolicy, History: history, Sessions: dispatcherpb.NewSessionDispatcherClient(dispatcherConnection), MessageBodyLimit: c.MessageBodyLimitBytes, OperatorReader: c.OperatorReaderAccount, - Debounce: c.NoticeDebounce.Debounce(), - RequestTimeout: seconds(c.RequestTimeoutSeconds), + OperatorReaderServices: c.OperatorReaderServices, + Debounce: c.NoticeDebounce.Debounce(), + RequestTimeout: seconds(c.RequestTimeoutSeconds), } // A worker that stops ends the process, so a ready replica's workers are running. health := probes.New(db, hub) methods := auth.HealthMethods() // A watch carries no deadline of its own, so the request timeout bounds its admission. - maps.Copy(methods, rpc.Methods(options, callers)) + notificationMethods, err := rpc.Methods(options, callers) + if err != nil { + return fmt.Errorf("configure Notification Service methods: %w", err) + } + maps.Copy(methods, notificationMethods) serverOptions := append(methods.ServerOptions(options.RequestTimeout), observability.GRPCServerOption()) security, err := c.RPC.ServerCredentials(peertls.NotificationsURI) if err != nil { diff --git a/agentplane/notification_service/settings.py b/agentplane/notification_service/settings.py index 46166968fc0..4d163cc66bc 100644 --- a/agentplane/notification_service/settings.py +++ b/agentplane/notification_service/settings.py @@ -6,8 +6,8 @@ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, model_validator -from agentplane.identity.auth.listener import KubernetesBearerAuth, Listener -from agentplane.net.peertls.config import ClientTLS +from agentplane.identity.auth.listener import KubernetesBearerAuth, Listener, SPIFFEAuth +from agentplane.net.peertls.config import ClientTLS, ServiceSPIFFEID from util.urls import HttpEndpointUrl TOKEN_AUDIENCE = "agentplane-notifications" @@ -136,6 +136,11 @@ class Settings(BaseModel): operator_reader_account: str | None = Field( default=None, description="ServiceAccount name allowed to inspect Session inbox diagnostics and entry payloads." ) + operator_reader_services: list[ServiceSPIFFEID] = Field( + default_factory=list, + exclude_if=lambda value: not value, + description="Exact service SVIDs admitted to read-only Session diagnostics; they do not assert an agent Identity.", + ) quotas: QuotaSettings = Field(default_factory=QuotaSettings) notice_debounce: NoticeDebounceSettings = Field(default_factory=NoticeDebounceSettings) github_delivery_retention_days: int | None = Field( @@ -175,6 +180,14 @@ def bearer_audience(self) -> Settings: raise ValueError("rpc.auth must allow the configured token_audience") return self + @model_validator(mode="after") + def service_operator_readers(self) -> Settings: + if len(set(self.operator_reader_services)) != len(self.operator_reader_services): + raise ValueError("operator_reader_services must not contain duplicates") + if self.operator_reader_services and not any(isinstance(method, SPIFFEAuth) for method in self.rpc.auth): + raise ValueError("operator_reader_services requires rpc SPIFFE client trust") + return self + github: GitHubSettings | None = Field( default=None, description="GitHub source configuration. Disabled when absent from the settings file." ) diff --git a/agentplane/notification_service/test_settings.py b/agentplane/notification_service/test_settings.py index 9c5a206e693..2a3052a5798 100644 --- a/agentplane/notification_service/test_settings.py +++ b/agentplane/notification_service/test_settings.py @@ -51,6 +51,26 @@ def test_settings_are_typed_and_include_nested_defaults() -> None: assert settings.request_timeout_s == 15 assert settings.message_body_limit_bytes == 32 * 1024 assert settings.github_delivery_retention_days == 7 + assert "operator_reader_services" not in settings.model_dump() + + +def test_operator_reader_account_and_service_svid_can_coexist() -> None: + values = static_values() + values["operator_reader_account"] = "test-app" + values["operator_reader_services"] = ["spiffe://agentplane/app"] + values["rpc"] = { + **static_values()["rpc"], + "auth": [ + {"mode": "kubernetes_bearer", "audiences": ["agentplane-notifications"]}, + {"mode": "spiffe", "ca_file": "/trust/workload-ca.pem"}, + ], + "tls": {"cert_file": "/tls/tls.crt", "key_file": "/tls/tls.key"}, + } + + settings = Settings.model_validate(values) + + assert settings.operator_reader_account == "test-app" + assert settings.operator_reader_services == ["spiffe://agentplane/app"] def test_generic_environment_does_not_override_static_values(monkeypatch: pytest.MonkeyPatch) -> None: From f09d68ab7060352abcaf03de845a504dc321f56b Mon Sep 17 00:00:00 2001 From: agentydragon-agent <260791330+agentydragon-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:11:20 -0800 Subject: [PATCH 2/2] Type the Notification TLS settings test input --- agentplane/notification_service/test_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentplane/notification_service/test_settings.py b/agentplane/notification_service/test_settings.py index 2a3052a5798..bb18190d908 100644 --- a/agentplane/notification_service/test_settings.py +++ b/agentplane/notification_service/test_settings.py @@ -59,7 +59,7 @@ def test_operator_reader_account_and_service_svid_can_coexist() -> None: values["operator_reader_account"] = "test-app" values["operator_reader_services"] = ["spiffe://agentplane/app"] values["rpc"] = { - **static_values()["rpc"], + "listen_address": "0.0.0.0:8080", "auth": [ {"mode": "kubernetes_bearer", "audiences": ["agentplane-notifications"]}, {"mode": "spiffe", "ca_file": "/trust/workload-ca.pem"},