diff --git a/.claude/skills/land-a-pull-request/SKILL.md b/.claude/skills/land-a-pull-request/SKILL.md index 0ce249c66..8abf4bdd6 100644 --- a/.claude/skills/land-a-pull-request/SKILL.md +++ b/.claude/skills/land-a-pull-request/SKILL.md @@ -86,6 +86,29 @@ already in `.git/hooks` stops running until you unset it. The hook is the only half that runs before publication — CI blocks the *merge*, by which point the commit is already public and GitHub keeps prior revisions of an edited body. +### Fix the body BEFORE you push, or the same failure survives the fix + +**Two** workflows run the record check, over the same terms, from different +triggers, and both read `github.event.pull_request.body` — the event payload, +frozen when the event fired, never the live body: + +- `naming-records.yml` listens for `edited`, so editing the body fires a fresh + event and that job turns green immediately. +- `ci.yml` takes `pull_request`'s default types (opened, synchronize, reopened), + so its copy — a step of the **Base manifest integrity** job — keeps evaluating + the body as it stood at the last push. It is deliberately not re-run on every + description edit. + +So fixing the body after pushing leaves you with `naming-records` green and +`Base manifest integrity` red **on a body that no longer exists**, which reads +like a flake and is not one. Edit the title and body first, push afterwards, and +one run clears both. If you have already pushed, the only way forward is another +push — re-running `ci.yml` replays the original payload and fails identically. + +Reading the live body instead would be the wrong fix: the gate scans what was +actually published, and an edit does not retract it, because GitHub serves prior +revisions of an edited body through its API. + ## A rejected commit identity Every commit a pull request adds is checked for an author and committer email diff --git a/RLS_PLAN.md b/RLS_PLAN.md index 4d465e700..48bd0b146 100644 --- a/RLS_PLAN.md +++ b/RLS_PLAN.md @@ -31,7 +31,7 @@ running queries. - ✅ Empty-orgID guard. - ✅ Connection-level role downgrade (`BeforeAcquire` SET ROLE app_tenant) — un-wrapped Store calls return zero rows by default. -- ✅ Phase 1 — `audit_export_configs` (migration 23). +- ✅ Phase 1 — `audit_export_configs` (migration 25). - ✅ Phase 2A — `webhook_subscriptions`, `webhook_deliveries` (27), `api_keys` (28). - ✅ Phase 2B — `org_settings`, `invitations`, `organization_members`, diff --git a/configurations/local/policy-log.env b/configurations/local/policy-log.env new file mode 120000 index 000000000..da2b30d35 --- /dev/null +++ b/configurations/local/policy-log.env @@ -0,0 +1 @@ +../../module/configurations/local/policy-log.env \ No newline at end of file diff --git a/configurations/local/policy-log.secret.env b/configurations/local/policy-log.secret.env new file mode 120000 index 000000000..64d494965 --- /dev/null +++ b/configurations/local/policy-log.secret.env @@ -0,0 +1 @@ +../../module/configurations/local/policy-log.secret.env \ No newline at end of file diff --git a/libraries/source-read-sdk/contract/catalog.codefly.json b/libraries/source-read-sdk/contract/catalog.codefly.json index 60d91e2f4..f1bd6dd17 100644 --- a/libraries/source-read-sdk/contract/catalog.codefly.json +++ b/libraries/source-read-sdk/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:0e4456c65985a563695d7408fa2d201e6d1a69752288cce3edc6ca79948579d0", + "digest": "sha256:b8ef1ba87e5d9b96c067194fa0fb07892c2ba937c623e54a8c5290cc6609cab8", "services": [ { "name": "ModuleCapabilitiesService", @@ -20,18 +20,25 @@ "/saas.accounts.v1.ModuleCapabilitiesService/CancelApproval", "/saas.accounts.v1.ModuleCapabilitiesService/CheckWorkContextRecordAccess", "/saas.accounts.v1.ModuleCapabilitiesService/ClaimJobs", + "/saas.accounts.v1.ModuleCapabilitiesService/DeclareAuditEventTypes", "/saas.accounts.v1.ModuleCapabilitiesService/EmitAuditEvent", "/saas.accounts.v1.ModuleCapabilitiesService/EnqueueJob", + "/saas.accounts.v1.ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", "/saas.accounts.v1.ModuleCapabilitiesService/ExchangeDelegatedReadAudience", "/saas.accounts.v1.ModuleCapabilitiesService/FetchDatasourceBlob", + "/saas.accounts.v1.ModuleCapabilitiesService/FetchDatasourceFiles", "/saas.accounts.v1.ModuleCapabilitiesService/GetApproval", "/saas.accounts.v1.ModuleCapabilitiesService/HeartbeatJob", "/saas.accounts.v1.ModuleCapabilitiesService/ListReadableSourceCollections", + "/saas.accounts.v1.ModuleCapabilitiesService/ListSubjectVisibility", "/saas.accounts.v1.ModuleCapabilitiesService/ListSubscriptions", + "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleOperationContext", "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration", "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleWorkContext", "/saas.accounts.v1.ModuleCapabilitiesService/MintSolutionRegistration", + "/saas.accounts.v1.ModuleCapabilitiesService/MintSourceOperationContext", "/saas.accounts.v1.ModuleCapabilitiesService/NackJob", + "/saas.accounts.v1.ModuleCapabilitiesService/NotifyOrgAdmins", "/saas.accounts.v1.ModuleCapabilitiesService/NotifyUser", "/saas.accounts.v1.ModuleCapabilitiesService/PlaceRecord", "/saas.accounts.v1.ModuleCapabilitiesService/PublishEvent", diff --git a/libraries/source-read-sdk/contract/contract.binpb b/libraries/source-read-sdk/contract/contract.binpb index 3cae69094..cd1c7b166 100644 Binary files a/libraries/source-read-sdk/contract/contract.binpb and b/libraries/source-read-sdk/contract/contract.binpb differ diff --git a/libraries/source-read-sdk/go/accounts_facade.pb.go b/libraries/source-read-sdk/go/accounts_facade.pb.go index 081fb6e64..36f395033 100644 --- a/libraries/source-read-sdk/go/accounts_facade.pb.go +++ b/libraries/source-read-sdk/go/accounts_facade.pb.go @@ -1,14 +1,15 @@ -// generated by protoc-gen-codefly-facade-go from saas.accounts.v1; do not edit +// Code generated by protoc-gen-codefly-facade-go from saas.accounts.v1. DO NOT EDIT. package accounts import ( - connect "connectrpc.com/connect" context "context" + http "net/http" + + connect "connectrpc.com/connect" v1 "github.com/codefly-dev/module-saas-starter/libraries/source-read-sdk/go/gen/saas/accounts/v1" accountsv1connect "github.com/codefly-dev/module-saas-starter/libraries/source-read-sdk/go/gen/saas/accounts/v1/accountsv1connect" emptypb "google.golang.org/protobuf/types/known/emptypb" - http "net/http" ) // Gateway is the minimal surface this facade needs from the solution runtime. @@ -48,6 +49,14 @@ func (a *ModuleCapabilitiesClient) ExchangeDelegatedReadAudience(ctx context.Con return resp.Msg, nil } +func (a *ModuleCapabilitiesClient) ExchangeDelegatedOperationAudience(ctx context.Context, req *v1.ModuleExchangeDelegatedOperationAudienceRequest) (*v1.IssuedWorkContext, error) { + resp, err := a.inner.ExchangeDelegatedOperationAudience(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + func (a *ModuleCapabilitiesClient) CheckWorkContextRecordAccess(ctx context.Context, req *v1.CheckWorkContextRecordAccessRequest) (*v1.CheckWorkContextRecordAccessResponse, error) { resp, err := a.inner.CheckWorkContextRecordAccess(ctx, connect.NewRequest(req)) if err != nil { @@ -120,6 +129,14 @@ func (a *ModuleCapabilitiesClient) NotifyUser(ctx context.Context, req *v1.Modul return resp.Msg, nil } +func (a *ModuleCapabilitiesClient) NotifyOrgAdmins(ctx context.Context, req *v1.ModuleNotifyOrgAdminsRequest) (*v1.ModuleNotifyOrgAdminsResponse, error) { + resp, err := a.inner.NotifyOrgAdmins(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + func (a *ModuleCapabilitiesClient) RequestApproval(ctx context.Context, req *v1.ModuleRequestApprovalRequest) (*v1.ModuleRequestApprovalResponse, error) { resp, err := a.inner.RequestApproval(ctx, connect.NewRequest(req)) if err != nil { @@ -152,8 +169,26 @@ func (a *ModuleCapabilitiesClient) EmitAuditEvent(ctx context.Context, req *v1.M return resp.Msg, nil } +func (a *ModuleCapabilitiesClient) DeclareAuditEventTypes(ctx context.Context, req *v1.ModuleDeclareAuditEventTypesRequest) (*v1.ModuleDeclareAuditEventTypesResponse, error) { + resp, err := a.inner.DeclareAuditEventTypes(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + +func (a *ModuleCapabilitiesClient) ListSubjectVisibility(ctx context.Context, req *v1.ModuleListSubjectVisibilityRequest) (*v1.ModuleListSubjectVisibilityResponse, error) { + resp, err := a.inner.ListSubjectVisibility(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + // skipped: streaming RPC FetchDatasourceBlob +// skipped: streaming RPC FetchDatasourceFiles + func (a *ModuleCapabilitiesClient) MintModuleRegistration(ctx context.Context, req *v1.ModuleMintRegistrationRequest) (*v1.ModuleMintRegistrationResponse, error) { resp, err := a.inner.MintModuleRegistration(ctx, connect.NewRequest(req)) if err != nil { @@ -178,6 +213,22 @@ func (a *ModuleCapabilitiesClient) MintModuleWorkContext(ctx context.Context, re return resp.Msg, nil } +func (a *ModuleCapabilitiesClient) MintModuleOperationContext(ctx context.Context, req *v1.ModuleMintOperationContextRequest) (*v1.ModuleMintOperationContextResponse, error) { + resp, err := a.inner.MintModuleOperationContext(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + +func (a *ModuleCapabilitiesClient) MintSourceOperationContext(ctx context.Context, req *v1.ModuleMintSourceOperationContextRequest) (*v1.ModuleMintSourceOperationContextResponse, error) { + resp, err := a.inner.MintSourceOperationContext(ctx, connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + func (a *ModuleCapabilitiesClient) PublishEvent(ctx context.Context, req *v1.ModulePublishEventRequest) (*v1.ModulePublishEventResponse, error) { resp, err := a.inner.PublishEvent(ctx, connect.NewRequest(req)) if err != nil { diff --git a/libraries/source-read-sdk/go/gen/saas/accounts/v1/accountsv1connect/module_capabilities.connect.go b/libraries/source-read-sdk/go/gen/saas/accounts/v1/accountsv1connect/module_capabilities.connect.go index dacd45ac2..22300fa11 100644 --- a/libraries/source-read-sdk/go/gen/saas/accounts/v1/accountsv1connect/module_capabilities.connect.go +++ b/libraries/source-read-sdk/go/gen/saas/accounts/v1/accountsv1connect/module_capabilities.connect.go @@ -39,6 +39,9 @@ const ( // ModuleCapabilitiesServiceExchangeDelegatedReadAudienceProcedure is the fully-qualified name of // the ModuleCapabilitiesService's ExchangeDelegatedReadAudience RPC. ModuleCapabilitiesServiceExchangeDelegatedReadAudienceProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/ExchangeDelegatedReadAudience" + // ModuleCapabilitiesServiceExchangeDelegatedOperationAudienceProcedure is the fully-qualified name + // of the ModuleCapabilitiesService's ExchangeDelegatedOperationAudience RPC. + ModuleCapabilitiesServiceExchangeDelegatedOperationAudienceProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/ExchangeDelegatedOperationAudience" // ModuleCapabilitiesServiceCheckWorkContextRecordAccessProcedure is the fully-qualified name of the // ModuleCapabilitiesService's CheckWorkContextRecordAccess RPC. ModuleCapabilitiesServiceCheckWorkContextRecordAccessProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/CheckWorkContextRecordAccess" @@ -66,6 +69,9 @@ const ( // ModuleCapabilitiesServiceNotifyUserProcedure is the fully-qualified name of the // ModuleCapabilitiesService's NotifyUser RPC. ModuleCapabilitiesServiceNotifyUserProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/NotifyUser" + // ModuleCapabilitiesServiceNotifyOrgAdminsProcedure is the fully-qualified name of the + // ModuleCapabilitiesService's NotifyOrgAdmins RPC. + ModuleCapabilitiesServiceNotifyOrgAdminsProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/NotifyOrgAdmins" // ModuleCapabilitiesServiceRequestApprovalProcedure is the fully-qualified name of the // ModuleCapabilitiesService's RequestApproval RPC. ModuleCapabilitiesServiceRequestApprovalProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/RequestApproval" @@ -78,9 +84,18 @@ const ( // ModuleCapabilitiesServiceEmitAuditEventProcedure is the fully-qualified name of the // ModuleCapabilitiesService's EmitAuditEvent RPC. ModuleCapabilitiesServiceEmitAuditEventProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/EmitAuditEvent" + // ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure is the fully-qualified name of the + // ModuleCapabilitiesService's DeclareAuditEventTypes RPC. + ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/DeclareAuditEventTypes" + // ModuleCapabilitiesServiceListSubjectVisibilityProcedure is the fully-qualified name of the + // ModuleCapabilitiesService's ListSubjectVisibility RPC. + ModuleCapabilitiesServiceListSubjectVisibilityProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/ListSubjectVisibility" // ModuleCapabilitiesServiceFetchDatasourceBlobProcedure is the fully-qualified name of the // ModuleCapabilitiesService's FetchDatasourceBlob RPC. ModuleCapabilitiesServiceFetchDatasourceBlobProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/FetchDatasourceBlob" + // ModuleCapabilitiesServiceFetchDatasourceFilesProcedure is the fully-qualified name of the + // ModuleCapabilitiesService's FetchDatasourceFiles RPC. + ModuleCapabilitiesServiceFetchDatasourceFilesProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/FetchDatasourceFiles" // ModuleCapabilitiesServiceMintModuleRegistrationProcedure is the fully-qualified name of the // ModuleCapabilitiesService's MintModuleRegistration RPC. ModuleCapabilitiesServiceMintModuleRegistrationProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration" @@ -90,6 +105,12 @@ const ( // ModuleCapabilitiesServiceMintModuleWorkContextProcedure is the fully-qualified name of the // ModuleCapabilitiesService's MintModuleWorkContext RPC. ModuleCapabilitiesServiceMintModuleWorkContextProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleWorkContext" + // ModuleCapabilitiesServiceMintModuleOperationContextProcedure is the fully-qualified name of the + // ModuleCapabilitiesService's MintModuleOperationContext RPC. + ModuleCapabilitiesServiceMintModuleOperationContextProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleOperationContext" + // ModuleCapabilitiesServiceMintSourceOperationContextProcedure is the fully-qualified name of the + // ModuleCapabilitiesService's MintSourceOperationContext RPC. + ModuleCapabilitiesServiceMintSourceOperationContextProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/MintSourceOperationContext" // ModuleCapabilitiesServicePublishEventProcedure is the fully-qualified name of the // ModuleCapabilitiesService's PublishEvent RPC. ModuleCapabilitiesServicePublishEventProcedure = "/saas.accounts.v1.ModuleCapabilitiesService/PublishEvent" @@ -113,6 +134,10 @@ type ModuleCapabilitiesServiceClient interface { // ExchangeDelegatedReadAudience authenticates the module independently of a // current parent context and exchanges only its installed read-only binding. ExchangeDelegatedReadAudience(context.Context, *connect.Request[v1.ModuleExchangeDelegatedReadAudienceRequest]) (*connect.Response[v11.IssuedWorkContext], error) + // ExchangeDelegatedOperationAudience authenticates the module independently + // of a current parent context and exchanges only an installed operation's + // invoke scopes, or its read-only receipt-lookup subset. + ExchangeDelegatedOperationAudience(context.Context, *connect.Request[v1.ModuleExchangeDelegatedOperationAudienceRequest]) (*connect.Response[v11.IssuedWorkContext], error) // Checks current owner and every delegated actor against true record placement, // intersected with the verified capability's attenuated resource/action scope. CheckWorkContextRecordAccess(context.Context, *connect.Request[v1.CheckWorkContextRecordAccessRequest]) (*connect.Response[v1.CheckWorkContextRecordAccessResponse], error) @@ -137,6 +162,9 @@ type ModuleCapabilitiesServiceClient interface { NackJob(context.Context, *connect.Request[v1.ModuleNackJobRequest]) (*connect.Response[emptypb.Empty], error) // NotifyUser delivers a notification subject to category policy. NotifyUser(context.Context, *connect.Request[v1.ModuleNotifyUserRequest]) (*connect.Response[v1.ModuleNotifyUserResponse], error) + // NotifyOrgAdmins notifies a tenant's administrators, resolved at send time; + // see the request. + NotifyOrgAdmins(context.Context, *connect.Request[v1.ModuleNotifyOrgAdminsRequest]) (*connect.Response[v1.ModuleNotifyOrgAdminsResponse], error) // RequestApproval opens a pending approval whose resume job the module claims. RequestApproval(context.Context, *connect.Request[v1.ModuleRequestApprovalRequest]) (*connect.Response[v1.ModuleRequestApprovalResponse], error) // GetApproval returns one approval request on the caller's tenant. @@ -145,11 +173,28 @@ type ModuleCapabilitiesServiceClient interface { CancelApproval(context.Context, *connect.Request[v1.ModuleCancelApprovalRequest]) (*connect.Response[emptypb.Empty], error) // EmitAuditEvent records a registered audit event on the tenant's spine. EmitAuditEvent(context.Context, *connect.Request[v1.ModuleEmitAuditEventRequest]) (*connect.Response[emptypb.Empty], error) - // FetchDatasourceBlob streams one datasource file blob, re-fetched from the - // upstream provider, to the module that resolves a change set's blob sha. - // Authorized by the caller principal's datasource-queue grant and the source - // row's own org/boundary, not the request tenant. + // DeclareAuditEventTypes admits the audit event types a composed module owns, + // into the namespaces the operator bound to it; see the request. + DeclareAuditEventTypes(context.Context, *connect.Request[v1.ModuleDeclareAuditEventTypesRequest]) (*connect.Response[v1.ModuleDeclareAuditEventTypesResponse], error) + // ListSubjectVisibility projects the tenant's team tree onto one viewer: the + // whole set of other subjects whose rows that viewer may read. + ListSubjectVisibility(context.Context, *connect.Request[v1.ModuleListSubjectVisibilityRequest]) (*connect.Response[v1.ModuleListSubjectVisibilityResponse], error) + // FetchDatasourceBlob streams one datasource file blob to the module that + // resolves a change set's blob sha. Authorized by the caller principal's + // datasource-queue grant and the source row's own org/boundary, not the + // request tenant. Deprecated: one call per file; FetchDatasourceFiles serves a + // whole change set in one call. + // + // Deprecated: do not use. FetchDatasourceBlob(context.Context, *connect.Request[v1.FetchDatasourceBlobRequest]) (*connect.ServerStreamForClient[v1.FetchDatasourceBlobChunk], error) + // FetchDatasourceFiles streams a batch of files of one source at one pinned + // version, read from a single fetch of that version shared by the whole batch. + // Authorized like FetchDatasourceBlob: the caller principal's datasource-queue + // grant and the source row's own org/boundary. A provider rate limit is + // RESOURCE_EXHAUSTED carrying an ErrorInfo (reason DATASOURCE_RATE_LIMITED, + // metadata reset_at) and a RetryInfo; a batch past its limits is + // FAILED_PRECONDITION carrying an ErrorInfo naming the limit. + FetchDatasourceFiles(context.Context, *connect.Request[v1.FetchDatasourceFilesRequest]) (*connect.ServerStreamForClient[v1.FetchDatasourceFilesFrame], error) // MintModuleRegistration issues the signed, prefix-bound credential a composed // module presents to the gateway to federate its REST surface. Authorized by // the module's own registration secret, not the shared cluster token. @@ -165,6 +210,24 @@ type ModuleCapabilitiesServiceClient interface { // from its registration prefix. Authorized by the module's own registration // secret, like the credential exchange above. MintModuleWorkContext(context.Context, *connect.Request[v1.ModuleMintWorkContextRequest]) (*connect.Response[v1.ModuleMintWorkContextResponse], error) + // MintModuleOperationContext issues, with no person present, a Work Context + // addressed to one of the calling module's installed operation audiences. + // Authorized by the module's identity secret like MintModuleWorkContext; owner + // and sole actor are the module service principal, the tenant is the one its + // principal declares, and the scopes are exactly the binding's declared + // headless scopes. A binding declaring none is refused. + MintModuleOperationContext(context.Context, *connect.Request[v1.ModuleMintOperationContextRequest]) (*connect.Response[v1.ModuleMintOperationContextResponse], error) + // MintSourceOperationContext issues the Work Context one datasource source's + // sync runs with: owned by the person who connected the source, actored by + // the calling module's service principal, in the source's organization, with + // exactly the source_delegation_scopes of the binding the person delegated + // to. Authorized by the module's identity secret like MintModuleWorkContext; + // every mint re-checks that the delegation is active, the source exists, the + // person is still an owner or admin of the organization, and the binding is + // unchanged. FAILED_PRECONDITION with reason DELEGATION_MISSING means the + // source has no active delegation to the module; PERMISSION_DENIED means the + // delegation named is revoked or unusable. + MintSourceOperationContext(context.Context, *connect.Request[v1.ModuleMintSourceOperationContextRequest]) (*connect.Response[v1.ModuleMintSourceOperationContextResponse], error) // PublishEvent appends one domain event to the outbox for the caller's tenant. PublishEvent(context.Context, *connect.Request[v1.ModulePublishEventRequest]) (*connect.Response[v1.ModulePublishEventResponse], error) // Subscribe creates or re-affirms a durable subscription for the caller. @@ -194,6 +257,12 @@ func NewModuleCapabilitiesServiceClient(httpClient connect.HTTPClient, baseURL s connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("ExchangeDelegatedReadAudience")), connect.WithClientOptions(opts...), ), + exchangeDelegatedOperationAudience: connect.NewClient[v1.ModuleExchangeDelegatedOperationAudienceRequest, v11.IssuedWorkContext]( + httpClient, + baseURL+ModuleCapabilitiesServiceExchangeDelegatedOperationAudienceProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("ExchangeDelegatedOperationAudience")), + connect.WithClientOptions(opts...), + ), checkWorkContextRecordAccess: connect.NewClient[v1.CheckWorkContextRecordAccessRequest, v1.CheckWorkContextRecordAccessResponse]( httpClient, baseURL+ModuleCapabilitiesServiceCheckWorkContextRecordAccessProcedure, @@ -248,6 +317,12 @@ func NewModuleCapabilitiesServiceClient(httpClient connect.HTTPClient, baseURL s connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("NotifyUser")), connect.WithClientOptions(opts...), ), + notifyOrgAdmins: connect.NewClient[v1.ModuleNotifyOrgAdminsRequest, v1.ModuleNotifyOrgAdminsResponse]( + httpClient, + baseURL+ModuleCapabilitiesServiceNotifyOrgAdminsProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("NotifyOrgAdmins")), + connect.WithClientOptions(opts...), + ), requestApproval: connect.NewClient[v1.ModuleRequestApprovalRequest, v1.ModuleRequestApprovalResponse]( httpClient, baseURL+ModuleCapabilitiesServiceRequestApprovalProcedure, @@ -272,12 +347,30 @@ func NewModuleCapabilitiesServiceClient(httpClient connect.HTTPClient, baseURL s connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("EmitAuditEvent")), connect.WithClientOptions(opts...), ), + declareAuditEventTypes: connect.NewClient[v1.ModuleDeclareAuditEventTypesRequest, v1.ModuleDeclareAuditEventTypesResponse]( + httpClient, + baseURL+ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("DeclareAuditEventTypes")), + connect.WithClientOptions(opts...), + ), + listSubjectVisibility: connect.NewClient[v1.ModuleListSubjectVisibilityRequest, v1.ModuleListSubjectVisibilityResponse]( + httpClient, + baseURL+ModuleCapabilitiesServiceListSubjectVisibilityProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("ListSubjectVisibility")), + connect.WithClientOptions(opts...), + ), fetchDatasourceBlob: connect.NewClient[v1.FetchDatasourceBlobRequest, v1.FetchDatasourceBlobChunk]( httpClient, baseURL+ModuleCapabilitiesServiceFetchDatasourceBlobProcedure, connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("FetchDatasourceBlob")), connect.WithClientOptions(opts...), ), + fetchDatasourceFiles: connect.NewClient[v1.FetchDatasourceFilesRequest, v1.FetchDatasourceFilesFrame]( + httpClient, + baseURL+ModuleCapabilitiesServiceFetchDatasourceFilesProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("FetchDatasourceFiles")), + connect.WithClientOptions(opts...), + ), mintModuleRegistration: connect.NewClient[v1.ModuleMintRegistrationRequest, v1.ModuleMintRegistrationResponse]( httpClient, baseURL+ModuleCapabilitiesServiceMintModuleRegistrationProcedure, @@ -296,6 +389,18 @@ func NewModuleCapabilitiesServiceClient(httpClient connect.HTTPClient, baseURL s connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("MintModuleWorkContext")), connect.WithClientOptions(opts...), ), + mintModuleOperationContext: connect.NewClient[v1.ModuleMintOperationContextRequest, v1.ModuleMintOperationContextResponse]( + httpClient, + baseURL+ModuleCapabilitiesServiceMintModuleOperationContextProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("MintModuleOperationContext")), + connect.WithClientOptions(opts...), + ), + mintSourceOperationContext: connect.NewClient[v1.ModuleMintSourceOperationContextRequest, v1.ModuleMintSourceOperationContextResponse]( + httpClient, + baseURL+ModuleCapabilitiesServiceMintSourceOperationContextProcedure, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("MintSourceOperationContext")), + connect.WithClientOptions(opts...), + ), publishEvent: connect.NewClient[v1.ModulePublishEventRequest, v1.ModulePublishEventResponse]( httpClient, baseURL+ModuleCapabilitiesServicePublishEventProcedure, @@ -331,29 +436,36 @@ func NewModuleCapabilitiesServiceClient(httpClient connect.HTTPClient, baseURL s // moduleCapabilitiesServiceClient implements ModuleCapabilitiesServiceClient. type moduleCapabilitiesServiceClient struct { - exchangeDelegatedReadAudience *connect.Client[v1.ModuleExchangeDelegatedReadAudienceRequest, v11.IssuedWorkContext] - checkWorkContextRecordAccess *connect.Client[v1.CheckWorkContextRecordAccessRequest, v1.CheckWorkContextRecordAccessResponse] - listReadableSourceCollections *connect.Client[v1.ListReadableSourceCollectionsRequest, v1.ListReadableSourceCollectionsResponse] - placeRecord *connect.Client[v1.ModulePlaceRecordRequest, v1.ModulePlaceRecordResponse] - enqueueJob *connect.Client[v1.ModuleEnqueueJobRequest, v1.ModuleEnqueueJobResponse] - claimJobs *connect.Client[v1.ModuleClaimJobsRequest, v1.ModuleClaimJobsResponse] - heartbeatJob *connect.Client[v1.ModuleHeartbeatJobRequest, v1.ModuleHeartbeatJobResponse] - ackJob *connect.Client[v1.ModuleAckJobRequest, emptypb.Empty] - nackJob *connect.Client[v1.ModuleNackJobRequest, emptypb.Empty] - notifyUser *connect.Client[v1.ModuleNotifyUserRequest, v1.ModuleNotifyUserResponse] - requestApproval *connect.Client[v1.ModuleRequestApprovalRequest, v1.ModuleRequestApprovalResponse] - getApproval *connect.Client[v1.ModuleGetApprovalRequest, v1.ModuleApproval] - cancelApproval *connect.Client[v1.ModuleCancelApprovalRequest, emptypb.Empty] - emitAuditEvent *connect.Client[v1.ModuleEmitAuditEventRequest, emptypb.Empty] - fetchDatasourceBlob *connect.Client[v1.FetchDatasourceBlobRequest, v1.FetchDatasourceBlobChunk] - mintModuleRegistration *connect.Client[v1.ModuleMintRegistrationRequest, v1.ModuleMintRegistrationResponse] - mintSolutionRegistration *connect.Client[v1.SolutionMintRegistrationRequest, v1.SolutionMintRegistrationResponse] - mintModuleWorkContext *connect.Client[v1.ModuleMintWorkContextRequest, v1.ModuleMintWorkContextResponse] - publishEvent *connect.Client[v1.ModulePublishEventRequest, v1.ModulePublishEventResponse] - subscribe *connect.Client[v1.ModuleSubscribeRequest, v1.ModuleSubscribeResponse] - unsubscribe *connect.Client[v1.ModuleUnsubscribeRequest, emptypb.Empty] - listSubscriptions *connect.Client[v1.ModuleListSubscriptionsRequest, v1.ModuleListSubscriptionsResponse] - replayEvents *connect.Client[v1.ModuleReplayEventsRequest, v1.ModuleReplayEventsResponse] + exchangeDelegatedReadAudience *connect.Client[v1.ModuleExchangeDelegatedReadAudienceRequest, v11.IssuedWorkContext] + exchangeDelegatedOperationAudience *connect.Client[v1.ModuleExchangeDelegatedOperationAudienceRequest, v11.IssuedWorkContext] + checkWorkContextRecordAccess *connect.Client[v1.CheckWorkContextRecordAccessRequest, v1.CheckWorkContextRecordAccessResponse] + listReadableSourceCollections *connect.Client[v1.ListReadableSourceCollectionsRequest, v1.ListReadableSourceCollectionsResponse] + placeRecord *connect.Client[v1.ModulePlaceRecordRequest, v1.ModulePlaceRecordResponse] + enqueueJob *connect.Client[v1.ModuleEnqueueJobRequest, v1.ModuleEnqueueJobResponse] + claimJobs *connect.Client[v1.ModuleClaimJobsRequest, v1.ModuleClaimJobsResponse] + heartbeatJob *connect.Client[v1.ModuleHeartbeatJobRequest, v1.ModuleHeartbeatJobResponse] + ackJob *connect.Client[v1.ModuleAckJobRequest, emptypb.Empty] + nackJob *connect.Client[v1.ModuleNackJobRequest, emptypb.Empty] + notifyUser *connect.Client[v1.ModuleNotifyUserRequest, v1.ModuleNotifyUserResponse] + notifyOrgAdmins *connect.Client[v1.ModuleNotifyOrgAdminsRequest, v1.ModuleNotifyOrgAdminsResponse] + requestApproval *connect.Client[v1.ModuleRequestApprovalRequest, v1.ModuleRequestApprovalResponse] + getApproval *connect.Client[v1.ModuleGetApprovalRequest, v1.ModuleApproval] + cancelApproval *connect.Client[v1.ModuleCancelApprovalRequest, emptypb.Empty] + emitAuditEvent *connect.Client[v1.ModuleEmitAuditEventRequest, emptypb.Empty] + declareAuditEventTypes *connect.Client[v1.ModuleDeclareAuditEventTypesRequest, v1.ModuleDeclareAuditEventTypesResponse] + listSubjectVisibility *connect.Client[v1.ModuleListSubjectVisibilityRequest, v1.ModuleListSubjectVisibilityResponse] + fetchDatasourceBlob *connect.Client[v1.FetchDatasourceBlobRequest, v1.FetchDatasourceBlobChunk] + fetchDatasourceFiles *connect.Client[v1.FetchDatasourceFilesRequest, v1.FetchDatasourceFilesFrame] + mintModuleRegistration *connect.Client[v1.ModuleMintRegistrationRequest, v1.ModuleMintRegistrationResponse] + mintSolutionRegistration *connect.Client[v1.SolutionMintRegistrationRequest, v1.SolutionMintRegistrationResponse] + mintModuleWorkContext *connect.Client[v1.ModuleMintWorkContextRequest, v1.ModuleMintWorkContextResponse] + mintModuleOperationContext *connect.Client[v1.ModuleMintOperationContextRequest, v1.ModuleMintOperationContextResponse] + mintSourceOperationContext *connect.Client[v1.ModuleMintSourceOperationContextRequest, v1.ModuleMintSourceOperationContextResponse] + publishEvent *connect.Client[v1.ModulePublishEventRequest, v1.ModulePublishEventResponse] + subscribe *connect.Client[v1.ModuleSubscribeRequest, v1.ModuleSubscribeResponse] + unsubscribe *connect.Client[v1.ModuleUnsubscribeRequest, emptypb.Empty] + listSubscriptions *connect.Client[v1.ModuleListSubscriptionsRequest, v1.ModuleListSubscriptionsResponse] + replayEvents *connect.Client[v1.ModuleReplayEventsRequest, v1.ModuleReplayEventsResponse] } // ExchangeDelegatedReadAudience calls @@ -362,6 +474,12 @@ func (c *moduleCapabilitiesServiceClient) ExchangeDelegatedReadAudience(ctx cont return c.exchangeDelegatedReadAudience.CallUnary(ctx, req) } +// ExchangeDelegatedOperationAudience calls +// saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedOperationAudience. +func (c *moduleCapabilitiesServiceClient) ExchangeDelegatedOperationAudience(ctx context.Context, req *connect.Request[v1.ModuleExchangeDelegatedOperationAudienceRequest]) (*connect.Response[v11.IssuedWorkContext], error) { + return c.exchangeDelegatedOperationAudience.CallUnary(ctx, req) +} + // CheckWorkContextRecordAccess calls // saas.accounts.v1.ModuleCapabilitiesService.CheckWorkContextRecordAccess. func (c *moduleCapabilitiesServiceClient) CheckWorkContextRecordAccess(ctx context.Context, req *connect.Request[v1.CheckWorkContextRecordAccessRequest]) (*connect.Response[v1.CheckWorkContextRecordAccessResponse], error) { @@ -409,6 +527,11 @@ func (c *moduleCapabilitiesServiceClient) NotifyUser(ctx context.Context, req *c return c.notifyUser.CallUnary(ctx, req) } +// NotifyOrgAdmins calls saas.accounts.v1.ModuleCapabilitiesService.NotifyOrgAdmins. +func (c *moduleCapabilitiesServiceClient) NotifyOrgAdmins(ctx context.Context, req *connect.Request[v1.ModuleNotifyOrgAdminsRequest]) (*connect.Response[v1.ModuleNotifyOrgAdminsResponse], error) { + return c.notifyOrgAdmins.CallUnary(ctx, req) +} + // RequestApproval calls saas.accounts.v1.ModuleCapabilitiesService.RequestApproval. func (c *moduleCapabilitiesServiceClient) RequestApproval(ctx context.Context, req *connect.Request[v1.ModuleRequestApprovalRequest]) (*connect.Response[v1.ModuleRequestApprovalResponse], error) { return c.requestApproval.CallUnary(ctx, req) @@ -429,11 +552,28 @@ func (c *moduleCapabilitiesServiceClient) EmitAuditEvent(ctx context.Context, re return c.emitAuditEvent.CallUnary(ctx, req) } +// DeclareAuditEventTypes calls saas.accounts.v1.ModuleCapabilitiesService.DeclareAuditEventTypes. +func (c *moduleCapabilitiesServiceClient) DeclareAuditEventTypes(ctx context.Context, req *connect.Request[v1.ModuleDeclareAuditEventTypesRequest]) (*connect.Response[v1.ModuleDeclareAuditEventTypesResponse], error) { + return c.declareAuditEventTypes.CallUnary(ctx, req) +} + +// ListSubjectVisibility calls saas.accounts.v1.ModuleCapabilitiesService.ListSubjectVisibility. +func (c *moduleCapabilitiesServiceClient) ListSubjectVisibility(ctx context.Context, req *connect.Request[v1.ModuleListSubjectVisibilityRequest]) (*connect.Response[v1.ModuleListSubjectVisibilityResponse], error) { + return c.listSubjectVisibility.CallUnary(ctx, req) +} + // FetchDatasourceBlob calls saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceBlob. +// +// Deprecated: do not use. func (c *moduleCapabilitiesServiceClient) FetchDatasourceBlob(ctx context.Context, req *connect.Request[v1.FetchDatasourceBlobRequest]) (*connect.ServerStreamForClient[v1.FetchDatasourceBlobChunk], error) { return c.fetchDatasourceBlob.CallServerStream(ctx, req) } +// FetchDatasourceFiles calls saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceFiles. +func (c *moduleCapabilitiesServiceClient) FetchDatasourceFiles(ctx context.Context, req *connect.Request[v1.FetchDatasourceFilesRequest]) (*connect.ServerStreamForClient[v1.FetchDatasourceFilesFrame], error) { + return c.fetchDatasourceFiles.CallServerStream(ctx, req) +} + // MintModuleRegistration calls saas.accounts.v1.ModuleCapabilitiesService.MintModuleRegistration. func (c *moduleCapabilitiesServiceClient) MintModuleRegistration(ctx context.Context, req *connect.Request[v1.ModuleMintRegistrationRequest]) (*connect.Response[v1.ModuleMintRegistrationResponse], error) { return c.mintModuleRegistration.CallUnary(ctx, req) @@ -450,6 +590,18 @@ func (c *moduleCapabilitiesServiceClient) MintModuleWorkContext(ctx context.Cont return c.mintModuleWorkContext.CallUnary(ctx, req) } +// MintModuleOperationContext calls +// saas.accounts.v1.ModuleCapabilitiesService.MintModuleOperationContext. +func (c *moduleCapabilitiesServiceClient) MintModuleOperationContext(ctx context.Context, req *connect.Request[v1.ModuleMintOperationContextRequest]) (*connect.Response[v1.ModuleMintOperationContextResponse], error) { + return c.mintModuleOperationContext.CallUnary(ctx, req) +} + +// MintSourceOperationContext calls +// saas.accounts.v1.ModuleCapabilitiesService.MintSourceOperationContext. +func (c *moduleCapabilitiesServiceClient) MintSourceOperationContext(ctx context.Context, req *connect.Request[v1.ModuleMintSourceOperationContextRequest]) (*connect.Response[v1.ModuleMintSourceOperationContextResponse], error) { + return c.mintSourceOperationContext.CallUnary(ctx, req) +} + // PublishEvent calls saas.accounts.v1.ModuleCapabilitiesService.PublishEvent. func (c *moduleCapabilitiesServiceClient) PublishEvent(ctx context.Context, req *connect.Request[v1.ModulePublishEventRequest]) (*connect.Response[v1.ModulePublishEventResponse], error) { return c.publishEvent.CallUnary(ctx, req) @@ -481,6 +633,10 @@ type ModuleCapabilitiesServiceHandler interface { // ExchangeDelegatedReadAudience authenticates the module independently of a // current parent context and exchanges only its installed read-only binding. ExchangeDelegatedReadAudience(context.Context, *connect.Request[v1.ModuleExchangeDelegatedReadAudienceRequest]) (*connect.Response[v11.IssuedWorkContext], error) + // ExchangeDelegatedOperationAudience authenticates the module independently + // of a current parent context and exchanges only an installed operation's + // invoke scopes, or its read-only receipt-lookup subset. + ExchangeDelegatedOperationAudience(context.Context, *connect.Request[v1.ModuleExchangeDelegatedOperationAudienceRequest]) (*connect.Response[v11.IssuedWorkContext], error) // Checks current owner and every delegated actor against true record placement, // intersected with the verified capability's attenuated resource/action scope. CheckWorkContextRecordAccess(context.Context, *connect.Request[v1.CheckWorkContextRecordAccessRequest]) (*connect.Response[v1.CheckWorkContextRecordAccessResponse], error) @@ -505,6 +661,9 @@ type ModuleCapabilitiesServiceHandler interface { NackJob(context.Context, *connect.Request[v1.ModuleNackJobRequest]) (*connect.Response[emptypb.Empty], error) // NotifyUser delivers a notification subject to category policy. NotifyUser(context.Context, *connect.Request[v1.ModuleNotifyUserRequest]) (*connect.Response[v1.ModuleNotifyUserResponse], error) + // NotifyOrgAdmins notifies a tenant's administrators, resolved at send time; + // see the request. + NotifyOrgAdmins(context.Context, *connect.Request[v1.ModuleNotifyOrgAdminsRequest]) (*connect.Response[v1.ModuleNotifyOrgAdminsResponse], error) // RequestApproval opens a pending approval whose resume job the module claims. RequestApproval(context.Context, *connect.Request[v1.ModuleRequestApprovalRequest]) (*connect.Response[v1.ModuleRequestApprovalResponse], error) // GetApproval returns one approval request on the caller's tenant. @@ -513,11 +672,28 @@ type ModuleCapabilitiesServiceHandler interface { CancelApproval(context.Context, *connect.Request[v1.ModuleCancelApprovalRequest]) (*connect.Response[emptypb.Empty], error) // EmitAuditEvent records a registered audit event on the tenant's spine. EmitAuditEvent(context.Context, *connect.Request[v1.ModuleEmitAuditEventRequest]) (*connect.Response[emptypb.Empty], error) - // FetchDatasourceBlob streams one datasource file blob, re-fetched from the - // upstream provider, to the module that resolves a change set's blob sha. - // Authorized by the caller principal's datasource-queue grant and the source - // row's own org/boundary, not the request tenant. + // DeclareAuditEventTypes admits the audit event types a composed module owns, + // into the namespaces the operator bound to it; see the request. + DeclareAuditEventTypes(context.Context, *connect.Request[v1.ModuleDeclareAuditEventTypesRequest]) (*connect.Response[v1.ModuleDeclareAuditEventTypesResponse], error) + // ListSubjectVisibility projects the tenant's team tree onto one viewer: the + // whole set of other subjects whose rows that viewer may read. + ListSubjectVisibility(context.Context, *connect.Request[v1.ModuleListSubjectVisibilityRequest]) (*connect.Response[v1.ModuleListSubjectVisibilityResponse], error) + // FetchDatasourceBlob streams one datasource file blob to the module that + // resolves a change set's blob sha. Authorized by the caller principal's + // datasource-queue grant and the source row's own org/boundary, not the + // request tenant. Deprecated: one call per file; FetchDatasourceFiles serves a + // whole change set in one call. + // + // Deprecated: do not use. FetchDatasourceBlob(context.Context, *connect.Request[v1.FetchDatasourceBlobRequest], *connect.ServerStream[v1.FetchDatasourceBlobChunk]) error + // FetchDatasourceFiles streams a batch of files of one source at one pinned + // version, read from a single fetch of that version shared by the whole batch. + // Authorized like FetchDatasourceBlob: the caller principal's datasource-queue + // grant and the source row's own org/boundary. A provider rate limit is + // RESOURCE_EXHAUSTED carrying an ErrorInfo (reason DATASOURCE_RATE_LIMITED, + // metadata reset_at) and a RetryInfo; a batch past its limits is + // FAILED_PRECONDITION carrying an ErrorInfo naming the limit. + FetchDatasourceFiles(context.Context, *connect.Request[v1.FetchDatasourceFilesRequest], *connect.ServerStream[v1.FetchDatasourceFilesFrame]) error // MintModuleRegistration issues the signed, prefix-bound credential a composed // module presents to the gateway to federate its REST surface. Authorized by // the module's own registration secret, not the shared cluster token. @@ -533,6 +709,24 @@ type ModuleCapabilitiesServiceHandler interface { // from its registration prefix. Authorized by the module's own registration // secret, like the credential exchange above. MintModuleWorkContext(context.Context, *connect.Request[v1.ModuleMintWorkContextRequest]) (*connect.Response[v1.ModuleMintWorkContextResponse], error) + // MintModuleOperationContext issues, with no person present, a Work Context + // addressed to one of the calling module's installed operation audiences. + // Authorized by the module's identity secret like MintModuleWorkContext; owner + // and sole actor are the module service principal, the tenant is the one its + // principal declares, and the scopes are exactly the binding's declared + // headless scopes. A binding declaring none is refused. + MintModuleOperationContext(context.Context, *connect.Request[v1.ModuleMintOperationContextRequest]) (*connect.Response[v1.ModuleMintOperationContextResponse], error) + // MintSourceOperationContext issues the Work Context one datasource source's + // sync runs with: owned by the person who connected the source, actored by + // the calling module's service principal, in the source's organization, with + // exactly the source_delegation_scopes of the binding the person delegated + // to. Authorized by the module's identity secret like MintModuleWorkContext; + // every mint re-checks that the delegation is active, the source exists, the + // person is still an owner or admin of the organization, and the binding is + // unchanged. FAILED_PRECONDITION with reason DELEGATION_MISSING means the + // source has no active delegation to the module; PERMISSION_DENIED means the + // delegation named is revoked or unusable. + MintSourceOperationContext(context.Context, *connect.Request[v1.ModuleMintSourceOperationContextRequest]) (*connect.Response[v1.ModuleMintSourceOperationContextResponse], error) // PublishEvent appends one domain event to the outbox for the caller's tenant. PublishEvent(context.Context, *connect.Request[v1.ModulePublishEventRequest]) (*connect.Response[v1.ModulePublishEventResponse], error) // Subscribe creates or re-affirms a durable subscription for the caller. @@ -558,6 +752,12 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("ExchangeDelegatedReadAudience")), connect.WithHandlerOptions(opts...), ) + moduleCapabilitiesServiceExchangeDelegatedOperationAudienceHandler := connect.NewUnaryHandler( + ModuleCapabilitiesServiceExchangeDelegatedOperationAudienceProcedure, + svc.ExchangeDelegatedOperationAudience, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("ExchangeDelegatedOperationAudience")), + connect.WithHandlerOptions(opts...), + ) moduleCapabilitiesServiceCheckWorkContextRecordAccessHandler := connect.NewUnaryHandler( ModuleCapabilitiesServiceCheckWorkContextRecordAccessProcedure, svc.CheckWorkContextRecordAccess, @@ -612,6 +812,12 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("NotifyUser")), connect.WithHandlerOptions(opts...), ) + moduleCapabilitiesServiceNotifyOrgAdminsHandler := connect.NewUnaryHandler( + ModuleCapabilitiesServiceNotifyOrgAdminsProcedure, + svc.NotifyOrgAdmins, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("NotifyOrgAdmins")), + connect.WithHandlerOptions(opts...), + ) moduleCapabilitiesServiceRequestApprovalHandler := connect.NewUnaryHandler( ModuleCapabilitiesServiceRequestApprovalProcedure, svc.RequestApproval, @@ -636,12 +842,30 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("EmitAuditEvent")), connect.WithHandlerOptions(opts...), ) + moduleCapabilitiesServiceDeclareAuditEventTypesHandler := connect.NewUnaryHandler( + ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure, + svc.DeclareAuditEventTypes, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("DeclareAuditEventTypes")), + connect.WithHandlerOptions(opts...), + ) + moduleCapabilitiesServiceListSubjectVisibilityHandler := connect.NewUnaryHandler( + ModuleCapabilitiesServiceListSubjectVisibilityProcedure, + svc.ListSubjectVisibility, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("ListSubjectVisibility")), + connect.WithHandlerOptions(opts...), + ) moduleCapabilitiesServiceFetchDatasourceBlobHandler := connect.NewServerStreamHandler( ModuleCapabilitiesServiceFetchDatasourceBlobProcedure, svc.FetchDatasourceBlob, connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("FetchDatasourceBlob")), connect.WithHandlerOptions(opts...), ) + moduleCapabilitiesServiceFetchDatasourceFilesHandler := connect.NewServerStreamHandler( + ModuleCapabilitiesServiceFetchDatasourceFilesProcedure, + svc.FetchDatasourceFiles, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("FetchDatasourceFiles")), + connect.WithHandlerOptions(opts...), + ) moduleCapabilitiesServiceMintModuleRegistrationHandler := connect.NewUnaryHandler( ModuleCapabilitiesServiceMintModuleRegistrationProcedure, svc.MintModuleRegistration, @@ -660,6 +884,18 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("MintModuleWorkContext")), connect.WithHandlerOptions(opts...), ) + moduleCapabilitiesServiceMintModuleOperationContextHandler := connect.NewUnaryHandler( + ModuleCapabilitiesServiceMintModuleOperationContextProcedure, + svc.MintModuleOperationContext, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("MintModuleOperationContext")), + connect.WithHandlerOptions(opts...), + ) + moduleCapabilitiesServiceMintSourceOperationContextHandler := connect.NewUnaryHandler( + ModuleCapabilitiesServiceMintSourceOperationContextProcedure, + svc.MintSourceOperationContext, + connect.WithSchema(moduleCapabilitiesServiceMethods.ByName("MintSourceOperationContext")), + connect.WithHandlerOptions(opts...), + ) moduleCapabilitiesServicePublishEventHandler := connect.NewUnaryHandler( ModuleCapabilitiesServicePublishEventProcedure, svc.PublishEvent, @@ -694,6 +930,8 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o switch r.URL.Path { case ModuleCapabilitiesServiceExchangeDelegatedReadAudienceProcedure: moduleCapabilitiesServiceExchangeDelegatedReadAudienceHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceExchangeDelegatedOperationAudienceProcedure: + moduleCapabilitiesServiceExchangeDelegatedOperationAudienceHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceCheckWorkContextRecordAccessProcedure: moduleCapabilitiesServiceCheckWorkContextRecordAccessHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceListReadableSourceCollectionsProcedure: @@ -712,6 +950,8 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o moduleCapabilitiesServiceNackJobHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceNotifyUserProcedure: moduleCapabilitiesServiceNotifyUserHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceNotifyOrgAdminsProcedure: + moduleCapabilitiesServiceNotifyOrgAdminsHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceRequestApprovalProcedure: moduleCapabilitiesServiceRequestApprovalHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceGetApprovalProcedure: @@ -720,14 +960,24 @@ func NewModuleCapabilitiesServiceHandler(svc ModuleCapabilitiesServiceHandler, o moduleCapabilitiesServiceCancelApprovalHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceEmitAuditEventProcedure: moduleCapabilitiesServiceEmitAuditEventHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceDeclareAuditEventTypesProcedure: + moduleCapabilitiesServiceDeclareAuditEventTypesHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceListSubjectVisibilityProcedure: + moduleCapabilitiesServiceListSubjectVisibilityHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceFetchDatasourceBlobProcedure: moduleCapabilitiesServiceFetchDatasourceBlobHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceFetchDatasourceFilesProcedure: + moduleCapabilitiesServiceFetchDatasourceFilesHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceMintModuleRegistrationProcedure: moduleCapabilitiesServiceMintModuleRegistrationHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceMintSolutionRegistrationProcedure: moduleCapabilitiesServiceMintSolutionRegistrationHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceMintModuleWorkContextProcedure: moduleCapabilitiesServiceMintModuleWorkContextHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceMintModuleOperationContextProcedure: + moduleCapabilitiesServiceMintModuleOperationContextHandler.ServeHTTP(w, r) + case ModuleCapabilitiesServiceMintSourceOperationContextProcedure: + moduleCapabilitiesServiceMintSourceOperationContextHandler.ServeHTTP(w, r) case ModuleCapabilitiesServicePublishEventProcedure: moduleCapabilitiesServicePublishEventHandler.ServeHTTP(w, r) case ModuleCapabilitiesServiceSubscribeProcedure: @@ -751,6 +1001,10 @@ func (UnimplementedModuleCapabilitiesServiceHandler) ExchangeDelegatedReadAudien return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedReadAudience is not implemented")) } +func (UnimplementedModuleCapabilitiesServiceHandler) ExchangeDelegatedOperationAudience(context.Context, *connect.Request[v1.ModuleExchangeDelegatedOperationAudienceRequest]) (*connect.Response[v11.IssuedWorkContext], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedOperationAudience is not implemented")) +} + func (UnimplementedModuleCapabilitiesServiceHandler) CheckWorkContextRecordAccess(context.Context, *connect.Request[v1.CheckWorkContextRecordAccessRequest]) (*connect.Response[v1.CheckWorkContextRecordAccessResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.CheckWorkContextRecordAccess is not implemented")) } @@ -787,6 +1041,10 @@ func (UnimplementedModuleCapabilitiesServiceHandler) NotifyUser(context.Context, return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.NotifyUser is not implemented")) } +func (UnimplementedModuleCapabilitiesServiceHandler) NotifyOrgAdmins(context.Context, *connect.Request[v1.ModuleNotifyOrgAdminsRequest]) (*connect.Response[v1.ModuleNotifyOrgAdminsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.NotifyOrgAdmins is not implemented")) +} + func (UnimplementedModuleCapabilitiesServiceHandler) RequestApproval(context.Context, *connect.Request[v1.ModuleRequestApprovalRequest]) (*connect.Response[v1.ModuleRequestApprovalResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.RequestApproval is not implemented")) } @@ -803,10 +1061,22 @@ func (UnimplementedModuleCapabilitiesServiceHandler) EmitAuditEvent(context.Cont return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.EmitAuditEvent is not implemented")) } +func (UnimplementedModuleCapabilitiesServiceHandler) DeclareAuditEventTypes(context.Context, *connect.Request[v1.ModuleDeclareAuditEventTypesRequest]) (*connect.Response[v1.ModuleDeclareAuditEventTypesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.DeclareAuditEventTypes is not implemented")) +} + +func (UnimplementedModuleCapabilitiesServiceHandler) ListSubjectVisibility(context.Context, *connect.Request[v1.ModuleListSubjectVisibilityRequest]) (*connect.Response[v1.ModuleListSubjectVisibilityResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.ListSubjectVisibility is not implemented")) +} + func (UnimplementedModuleCapabilitiesServiceHandler) FetchDatasourceBlob(context.Context, *connect.Request[v1.FetchDatasourceBlobRequest], *connect.ServerStream[v1.FetchDatasourceBlobChunk]) error { return connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceBlob is not implemented")) } +func (UnimplementedModuleCapabilitiesServiceHandler) FetchDatasourceFiles(context.Context, *connect.Request[v1.FetchDatasourceFilesRequest], *connect.ServerStream[v1.FetchDatasourceFilesFrame]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceFiles is not implemented")) +} + func (UnimplementedModuleCapabilitiesServiceHandler) MintModuleRegistration(context.Context, *connect.Request[v1.ModuleMintRegistrationRequest]) (*connect.Response[v1.ModuleMintRegistrationResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.MintModuleRegistration is not implemented")) } @@ -819,6 +1089,14 @@ func (UnimplementedModuleCapabilitiesServiceHandler) MintModuleWorkContext(conte return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.MintModuleWorkContext is not implemented")) } +func (UnimplementedModuleCapabilitiesServiceHandler) MintModuleOperationContext(context.Context, *connect.Request[v1.ModuleMintOperationContextRequest]) (*connect.Response[v1.ModuleMintOperationContextResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.MintModuleOperationContext is not implemented")) +} + +func (UnimplementedModuleCapabilitiesServiceHandler) MintSourceOperationContext(context.Context, *connect.Request[v1.ModuleMintSourceOperationContextRequest]) (*connect.Response[v1.ModuleMintSourceOperationContextResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.MintSourceOperationContext is not implemented")) +} + func (UnimplementedModuleCapabilitiesServiceHandler) PublishEvent(context.Context, *connect.Request[v1.ModulePublishEventRequest]) (*connect.Response[v1.ModulePublishEventResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.ModuleCapabilitiesService.PublishEvent is not implemented")) } diff --git a/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_capabilities.pb.go b/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_capabilities.pb.go index dd8ac68e1..d82edfdaa 100644 --- a/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_capabilities.pb.go +++ b/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_capabilities.pb.go @@ -30,6 +30,197 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ModuleAuditFieldKind is the kind of one declared audit payload field: the +// same kinds a solution may declare in its manifest. +type ModuleAuditFieldKind int32 + +const ( + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UNSPECIFIED ModuleAuditFieldKind = 0 + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING ModuleAuditFieldKind = 1 + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UUID ModuleAuditFieldKind = 2 + // A whole number (a JSON number with no fractional part, within 2^53). + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_INT ModuleAuditFieldKind = 3 + // A finite number. + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_NUMBER ModuleAuditFieldKind = 4 + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_BOOL ModuleAuditFieldKind = 5 + // One of `values`. + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_ENUM ModuleAuditFieldKind = 6 + ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_STRING_ARRAY ModuleAuditFieldKind = 7 +) + +// Enum value maps for ModuleAuditFieldKind. +var ( + ModuleAuditFieldKind_name = map[int32]string{ + 0: "MODULE_AUDIT_FIELD_KIND_UNSPECIFIED", + 1: "MODULE_AUDIT_FIELD_KIND_STRING", + 2: "MODULE_AUDIT_FIELD_KIND_UUID", + 3: "MODULE_AUDIT_FIELD_KIND_INT", + 4: "MODULE_AUDIT_FIELD_KIND_NUMBER", + 5: "MODULE_AUDIT_FIELD_KIND_BOOL", + 6: "MODULE_AUDIT_FIELD_KIND_ENUM", + 7: "MODULE_AUDIT_FIELD_KIND_STRING_ARRAY", + } + ModuleAuditFieldKind_value = map[string]int32{ + "MODULE_AUDIT_FIELD_KIND_UNSPECIFIED": 0, + "MODULE_AUDIT_FIELD_KIND_STRING": 1, + "MODULE_AUDIT_FIELD_KIND_UUID": 2, + "MODULE_AUDIT_FIELD_KIND_INT": 3, + "MODULE_AUDIT_FIELD_KIND_NUMBER": 4, + "MODULE_AUDIT_FIELD_KIND_BOOL": 5, + "MODULE_AUDIT_FIELD_KIND_ENUM": 6, + "MODULE_AUDIT_FIELD_KIND_STRING_ARRAY": 7, + } +) + +func (x ModuleAuditFieldKind) Enum() *ModuleAuditFieldKind { + p := new(ModuleAuditFieldKind) + *p = x + return p +} + +func (x ModuleAuditFieldKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ModuleAuditFieldKind) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_module_capabilities_proto_enumTypes[0].Descriptor() +} + +func (ModuleAuditFieldKind) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[0] +} + +func (x ModuleAuditFieldKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ModuleAuditFieldKind.Descriptor instead. +func (ModuleAuditFieldKind) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{0} +} + +// ModuleAuditEventVisibility is how far an event of a declared audit type may +// travel. It is the vocabulary the composed event catalog already declares its +// published events under, narrowed to the two values a declaration can mean: +// an audit record is always readable by its own tenant, so `internal` has no +// reading here. +type ModuleAuditEventVisibility int32 + +const ( + // Unset means TENANT. A declaration that says nothing keeps its events + // inside the platform, because eligibility to leave is granted by an + // explicit declaration and never by omission. + ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED ModuleAuditEventVisibility = 0 + // Recorded in the tenant's audit log and never delivered outside the + // platform. + ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_TENANT ModuleAuditEventVisibility = 1 + // May additionally be delivered to a tenant's outbound webhook endpoint. + // Admitted only in a namespace the operator marked externally deliverable + // (`external_namespaces` of the declaring principal's MODULE_PRINCIPALS + // entry): the producer decides which of its facts are customer-facing, and + // the operator decides whether that producer may send anything out at all. + ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL ModuleAuditEventVisibility = 2 +) + +// Enum value maps for ModuleAuditEventVisibility. +var ( + ModuleAuditEventVisibility_name = map[int32]string{ + 0: "MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED", + 1: "MODULE_AUDIT_EVENT_VISIBILITY_TENANT", + 2: "MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL", + } + ModuleAuditEventVisibility_value = map[string]int32{ + "MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED": 0, + "MODULE_AUDIT_EVENT_VISIBILITY_TENANT": 1, + "MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL": 2, + } +) + +func (x ModuleAuditEventVisibility) Enum() *ModuleAuditEventVisibility { + p := new(ModuleAuditEventVisibility) + *p = x + return p +} + +func (x ModuleAuditEventVisibility) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ModuleAuditEventVisibility) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_module_capabilities_proto_enumTypes[1].Descriptor() +} + +func (ModuleAuditEventVisibility) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[1] +} + +func (x ModuleAuditEventVisibility) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ModuleAuditEventVisibility.Descriptor instead. +func (ModuleAuditEventVisibility) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{1} +} + +// DatasourceItemReadersBasis says where an item's readers came from. +type DatasourceItemReadersBasis int32 + +const ( + DatasourceItemReadersBasis_DATASOURCE_ITEM_READERS_BASIS_UNSPECIFIED DatasourceItemReadersBasis = 0 + // The provider's own per-item access list, translated into host identities. + DatasourceItemReadersBasis_DATASOURCE_ITEM_READERS_BASIS_TRANSLATED DatasourceItemReadersBasis = 1 + // The item has no access list of its own: whoever may read the source's + // boundary may read the item. + DatasourceItemReadersBasis_DATASOURCE_ITEM_READERS_BASIS_SOURCE_SCOPED DatasourceItemReadersBasis = 2 + // The provider has a per-item access list the host cannot translate. A + // consumer must fail closed on it rather than widen it to the boundary. + DatasourceItemReadersBasis_DATASOURCE_ITEM_READERS_BASIS_UNTRANSLATABLE DatasourceItemReadersBasis = 3 +) + +// Enum value maps for DatasourceItemReadersBasis. +var ( + DatasourceItemReadersBasis_name = map[int32]string{ + 0: "DATASOURCE_ITEM_READERS_BASIS_UNSPECIFIED", + 1: "DATASOURCE_ITEM_READERS_BASIS_TRANSLATED", + 2: "DATASOURCE_ITEM_READERS_BASIS_SOURCE_SCOPED", + 3: "DATASOURCE_ITEM_READERS_BASIS_UNTRANSLATABLE", + } + DatasourceItemReadersBasis_value = map[string]int32{ + "DATASOURCE_ITEM_READERS_BASIS_UNSPECIFIED": 0, + "DATASOURCE_ITEM_READERS_BASIS_TRANSLATED": 1, + "DATASOURCE_ITEM_READERS_BASIS_SOURCE_SCOPED": 2, + "DATASOURCE_ITEM_READERS_BASIS_UNTRANSLATABLE": 3, + } +) + +func (x DatasourceItemReadersBasis) Enum() *DatasourceItemReadersBasis { + p := new(DatasourceItemReadersBasis) + *p = x + return p +} + +func (x DatasourceItemReadersBasis) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DatasourceItemReadersBasis) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_module_capabilities_proto_enumTypes[2].Descriptor() +} + +func (DatasourceItemReadersBasis) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[2] +} + +func (x DatasourceItemReadersBasis) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DatasourceItemReadersBasis.Descriptor instead. +func (DatasourceItemReadersBasis) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{2} +} + // EventDelivery is the ordering guarantee a subscription requests, mirroring // events.Delivery in the SDK: UNORDERED fans out with no per-key ordering; // ORDERED preserves FIFO within an event's partition key. @@ -66,11 +257,11 @@ func (x EventDelivery) String() string { } func (EventDelivery) Descriptor() protoreflect.EnumDescriptor { - return file_saas_accounts_v1_module_capabilities_proto_enumTypes[0].Descriptor() + return file_saas_accounts_v1_module_capabilities_proto_enumTypes[3].Descriptor() } func (EventDelivery) Type() protoreflect.EnumType { - return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[0] + return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[3] } func (x EventDelivery) Number() protoreflect.EnumNumber { @@ -79,7 +270,7 @@ func (x EventDelivery) Number() protoreflect.EnumNumber { // Deprecated: Use EventDelivery.Descriptor instead. func (EventDelivery) EnumDescriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{0} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{3} } // MetadataDisclosure separates "the host withheld this from you" from "there is @@ -119,11 +310,11 @@ func (x MetadataDisclosure) String() string { } func (MetadataDisclosure) Descriptor() protoreflect.EnumDescriptor { - return file_saas_accounts_v1_module_capabilities_proto_enumTypes[1].Descriptor() + return file_saas_accounts_v1_module_capabilities_proto_enumTypes[4].Descriptor() } func (MetadataDisclosure) Type() protoreflect.EnumType { - return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[1] + return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[4] } func (x MetadataDisclosure) Number() protoreflect.EnumNumber { @@ -132,7 +323,7 @@ func (x MetadataDisclosure) Number() protoreflect.EnumNumber { // Deprecated: Use MetadataDisclosure.Descriptor instead. func (MetadataDisclosure) EnumDescriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{1} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{4} } // SourceSyncStage names which occurrence CollectionSyncProvenance.at records. @@ -172,11 +363,11 @@ func (x SourceSyncStage) String() string { } func (SourceSyncStage) Descriptor() protoreflect.EnumDescriptor { - return file_saas_accounts_v1_module_capabilities_proto_enumTypes[2].Descriptor() + return file_saas_accounts_v1_module_capabilities_proto_enumTypes[5].Descriptor() } func (SourceSyncStage) Type() protoreflect.EnumType { - return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[2] + return &file_saas_accounts_v1_module_capabilities_proto_enumTypes[5] } func (x SourceSyncStage) Number() protoreflect.EnumNumber { @@ -185,7 +376,7 @@ func (x SourceSyncStage) Number() protoreflect.EnumNumber { // Deprecated: Use SourceSyncStage.Descriptor instead. func (SourceSyncStage) EnumDescriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{2} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{5} } // ModuleEnqueueJobRequest names the tenant the work belongs to and carries the @@ -801,6 +992,155 @@ func (x *ModuleNotifyUserResponse) GetDelivered() bool { return false } +// ModuleNotifyOrgAdminsRequest notifies the administrators of one tenant — its +// members holding the admin or owner role, resolved when the call is made — for +// a condition only an administrator can act on (a revoked delegation, a source +// that lost access). The module never names a recipient and never learns who +// they are: the host resolves them and returns none. Category policy applies to +// each recipient as for NotifyUser, and idempotency_key dedupes redelivery per +// recipient. +type ModuleNotifyOrgAdminsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + // One of the notification types: info, success, warning, error, billing, + // security. Empty means info. + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` + ActionUrl string `protobuf:"bytes,5,opt,name=action_url,json=actionUrl,proto3" json:"action_url,omitempty"` + Category string `protobuf:"bytes,6,opt,name=category,proto3" json:"category,omitempty"` + IdempotencyKey string `protobuf:"bytes,7,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleNotifyOrgAdminsRequest) Reset() { + *x = ModuleNotifyOrgAdminsRequest{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleNotifyOrgAdminsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleNotifyOrgAdminsRequest) ProtoMessage() {} + +func (x *ModuleNotifyOrgAdminsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleNotifyOrgAdminsRequest.ProtoReflect.Descriptor instead. +func (*ModuleNotifyOrgAdminsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{10} +} + +func (x *ModuleNotifyOrgAdminsRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ModuleNotifyOrgAdminsRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *ModuleNotifyOrgAdminsRequest) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *ModuleNotifyOrgAdminsRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ModuleNotifyOrgAdminsRequest) GetActionUrl() string { + if x != nil { + return x.ActionUrl + } + return "" +} + +func (x *ModuleNotifyOrgAdminsRequest) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *ModuleNotifyOrgAdminsRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +// ModuleNotifyOrgAdminsResponse says only whether any administrator received the +// notification. It is false when the tenant has no administrator or category +// policy suppressed it for every one; the call still succeeds. It carries no +// recipient and no count. +type ModuleNotifyOrgAdminsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Delivered bool `protobuf:"varint,1,opt,name=delivered,proto3" json:"delivered,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleNotifyOrgAdminsResponse) Reset() { + *x = ModuleNotifyOrgAdminsResponse{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleNotifyOrgAdminsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleNotifyOrgAdminsResponse) ProtoMessage() {} + +func (x *ModuleNotifyOrgAdminsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleNotifyOrgAdminsResponse.ProtoReflect.Descriptor instead. +func (*ModuleNotifyOrgAdminsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{11} +} + +func (x *ModuleNotifyOrgAdminsResponse) GetDelivered() bool { + if x != nil { + return x.Delivered + } + return false +} + // ModuleApprovalPolicy is the non-resume half of the request policy. quorum is // the number of DISTINCT approve decisions required (0 defaults to 1). type ModuleApprovalPolicy struct { @@ -814,7 +1154,7 @@ type ModuleApprovalPolicy struct { func (x *ModuleApprovalPolicy) Reset() { *x = ModuleApprovalPolicy{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[10] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -826,7 +1166,7 @@ func (x *ModuleApprovalPolicy) String() string { func (*ModuleApprovalPolicy) ProtoMessage() {} func (x *ModuleApprovalPolicy) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[10] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -839,7 +1179,7 @@ func (x *ModuleApprovalPolicy) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleApprovalPolicy.ProtoReflect.Descriptor instead. func (*ModuleApprovalPolicy) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{10} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{12} } func (x *ModuleApprovalPolicy) GetQuorum() uint32 { @@ -878,7 +1218,7 @@ type ModuleResumeRef struct { func (x *ModuleResumeRef) Reset() { *x = ModuleResumeRef{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[11] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -890,7 +1230,7 @@ func (x *ModuleResumeRef) String() string { func (*ModuleResumeRef) ProtoMessage() {} func (x *ModuleResumeRef) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[11] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -903,7 +1243,7 @@ func (x *ModuleResumeRef) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleResumeRef.ProtoReflect.Descriptor instead. func (*ModuleResumeRef) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{11} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{13} } func (x *ModuleResumeRef) GetQueue() string { @@ -944,7 +1284,7 @@ type ModuleRequestApprovalRequest struct { func (x *ModuleRequestApprovalRequest) Reset() { *x = ModuleRequestApprovalRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[12] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -956,7 +1296,7 @@ func (x *ModuleRequestApprovalRequest) String() string { func (*ModuleRequestApprovalRequest) ProtoMessage() {} func (x *ModuleRequestApprovalRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[12] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -969,7 +1309,7 @@ func (x *ModuleRequestApprovalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleRequestApprovalRequest.ProtoReflect.Descriptor instead. func (*ModuleRequestApprovalRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{12} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{14} } func (x *ModuleRequestApprovalRequest) GetTenant() string { @@ -1044,7 +1384,7 @@ type ModuleRequestApprovalResponse struct { func (x *ModuleRequestApprovalResponse) Reset() { *x = ModuleRequestApprovalResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[13] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1056,7 +1396,7 @@ func (x *ModuleRequestApprovalResponse) String() string { func (*ModuleRequestApprovalResponse) ProtoMessage() {} func (x *ModuleRequestApprovalResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[13] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1069,7 +1409,7 @@ func (x *ModuleRequestApprovalResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleRequestApprovalResponse.ProtoReflect.Descriptor instead. func (*ModuleRequestApprovalResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{13} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{15} } func (x *ModuleRequestApprovalResponse) GetApprovalId() string { @@ -1089,7 +1429,7 @@ type ModuleGetApprovalRequest struct { func (x *ModuleGetApprovalRequest) Reset() { *x = ModuleGetApprovalRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[14] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1101,7 +1441,7 @@ func (x *ModuleGetApprovalRequest) String() string { func (*ModuleGetApprovalRequest) ProtoMessage() {} func (x *ModuleGetApprovalRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[14] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1114,7 +1454,7 @@ func (x *ModuleGetApprovalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleGetApprovalRequest.ProtoReflect.Descriptor instead. func (*ModuleGetApprovalRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{14} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{16} } func (x *ModuleGetApprovalRequest) GetTenant() string { @@ -1152,7 +1492,7 @@ type ModuleApproval struct { func (x *ModuleApproval) Reset() { *x = ModuleApproval{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[15] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1164,7 +1504,7 @@ func (x *ModuleApproval) String() string { func (*ModuleApproval) ProtoMessage() {} func (x *ModuleApproval) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[15] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1177,7 +1517,7 @@ func (x *ModuleApproval) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleApproval.ProtoReflect.Descriptor instead. func (*ModuleApproval) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{15} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{17} } func (x *ModuleApproval) GetId() string { @@ -1275,7 +1615,7 @@ type ModuleCancelApprovalRequest struct { func (x *ModuleCancelApprovalRequest) Reset() { *x = ModuleCancelApprovalRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[16] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1287,7 +1627,7 @@ func (x *ModuleCancelApprovalRequest) String() string { func (*ModuleCancelApprovalRequest) ProtoMessage() {} func (x *ModuleCancelApprovalRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[16] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1300,7 +1640,7 @@ func (x *ModuleCancelApprovalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleCancelApprovalRequest.ProtoReflect.Descriptor instead. func (*ModuleCancelApprovalRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{16} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{18} } func (x *ModuleCancelApprovalRequest) GetTenant() string { @@ -1349,7 +1689,7 @@ type ModuleEmitAuditEventRequest struct { func (x *ModuleEmitAuditEventRequest) Reset() { *x = ModuleEmitAuditEventRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[17] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1361,7 +1701,889 @@ func (x *ModuleEmitAuditEventRequest) String() string { func (*ModuleEmitAuditEventRequest) ProtoMessage() {} func (x *ModuleEmitAuditEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[17] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleEmitAuditEventRequest.ProtoReflect.Descriptor instead. +func (*ModuleEmitAuditEventRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{19} +} + +func (x *ModuleEmitAuditEventRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ModuleEmitAuditEventRequest) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *ModuleEmitAuditEventRequest) GetActor() string { + if x != nil { + return x.Actor + } + return "" +} + +func (x *ModuleEmitAuditEventRequest) GetSolution() string { + if x != nil { + return x.Solution + } + return "" +} + +func (x *ModuleEmitAuditEventRequest) GetEntryId() string { + if x != nil { + return x.EntryId + } + return "" +} + +func (x *ModuleEmitAuditEventRequest) GetFields() *structpb.Struct { + if x != nil { + return x.Fields + } + return nil +} + +func (x *ModuleEmitAuditEventRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +// ModuleAuditFieldDeclaration is one payload field of a declared audit event +// type. `solution` is stamped by the host from the emitting scope and may not +// be declared. +type ModuleAuditFieldDeclaration struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Lowercase snake_case, unique within the type. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Kind ModuleAuditFieldKind `protobuf:"varint,2,opt,name=kind,proto3,enum=saas.accounts.v1.ModuleAuditFieldKind" json:"kind,omitempty"` + // The admissible values of an ENUM field; refused on any other kind. + Values []string `protobuf:"bytes,3,rep,name=values,proto3" json:"values,omitempty"` + // A personally identifying field: stripped from every path that sends an + // event outside the audit store (webhooks, downloads, the export sink). + Pii bool `protobuf:"varint,4,opt,name=pii,proto3" json:"pii,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleAuditFieldDeclaration) Reset() { + *x = ModuleAuditFieldDeclaration{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleAuditFieldDeclaration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleAuditFieldDeclaration) ProtoMessage() {} + +func (x *ModuleAuditFieldDeclaration) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleAuditFieldDeclaration.ProtoReflect.Descriptor instead. +func (*ModuleAuditFieldDeclaration) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{20} +} + +func (x *ModuleAuditFieldDeclaration) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ModuleAuditFieldDeclaration) GetKind() ModuleAuditFieldKind { + if x != nil { + return x.Kind + } + return ModuleAuditFieldKind_MODULE_AUDIT_FIELD_KIND_UNSPECIFIED +} + +func (x *ModuleAuditFieldDeclaration) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +func (x *ModuleAuditFieldDeclaration) GetPii() bool { + if x != nil { + return x.Pii + } + return false +} + +// ModuleAuditEventTypeDeclaration is one audit event type a composed module +// declares for itself. +type ModuleAuditEventTypeDeclaration struct { + state protoimpl.MessageState `protogen:"open.v1"` + // .., lowercase snake_case segments. The + // namespace must be one the operator bound to this module (the `namespaces` + // of its MODULE_PRINCIPALS entry) and may not be the host's own. + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Fields []*ModuleAuditFieldDeclaration `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"` + // How far an event of this type may travel. Fixed at admission: a later + // declaration that names a different visibility is refused, because + // narrowing it would silently stop deliveries to endpoints already + // subscribed and widening it would start sending out facts a tenant's + // endpoint never agreed to receive under this name. + Visibility ModuleAuditEventVisibility `protobuf:"varint,4,opt,name=visibility,proto3,enum=saas.accounts.v1.ModuleAuditEventVisibility" json:"visibility,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleAuditEventTypeDeclaration) Reset() { + *x = ModuleAuditEventTypeDeclaration{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleAuditEventTypeDeclaration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleAuditEventTypeDeclaration) ProtoMessage() {} + +func (x *ModuleAuditEventTypeDeclaration) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleAuditEventTypeDeclaration.ProtoReflect.Descriptor instead. +func (*ModuleAuditEventTypeDeclaration) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{21} +} + +func (x *ModuleAuditEventTypeDeclaration) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ModuleAuditEventTypeDeclaration) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ModuleAuditEventTypeDeclaration) GetFields() []*ModuleAuditFieldDeclaration { + if x != nil { + return x.Fields + } + return nil +} + +func (x *ModuleAuditEventTypeDeclaration) GetVisibility() ModuleAuditEventVisibility { + if x != nil { + return x.Visibility + } + return ModuleAuditEventVisibility_MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED +} + +// ModuleDeclareAuditEventTypesRequest declares the audit event types a composed +// module owns, so its EmitAuditEvent calls for them are accepted and checked +// against the declared fields. It is the module-side form of a solution's +// manifest declaration, admitted by the same rules: fail closed on a namespace +// the operator did not bind to the module, and a type may only grow (fields +// and enum values may be added; none is dropped or retyped). Re-declaring what +// is already admitted writes nothing, so a module may declare on every start. +type ModuleDeclareAuditEventTypesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The module's own prefix: the caller must be the principal derived from + // it. Emissions of the declared types name the same value as `solution`. + Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` + Types []*ModuleAuditEventTypeDeclaration `protobuf:"bytes,2,rep,name=types,proto3" json:"types,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleDeclareAuditEventTypesRequest) Reset() { + *x = ModuleDeclareAuditEventTypesRequest{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleDeclareAuditEventTypesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleDeclareAuditEventTypesRequest) ProtoMessage() {} + +func (x *ModuleDeclareAuditEventTypesRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleDeclareAuditEventTypesRequest.ProtoReflect.Descriptor instead. +func (*ModuleDeclareAuditEventTypesRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{22} +} + +func (x *ModuleDeclareAuditEventTypesRequest) GetPrefix() string { + if x != nil { + return x.Prefix + } + return "" +} + +func (x *ModuleDeclareAuditEventTypesRequest) GetTypes() []*ModuleAuditEventTypeDeclaration { + if x != nil { + return x.Types + } + return nil +} + +// ModuleDeclareAuditEventTypesResponse names what the call changed: the types +// it admitted or grew, and the namespaces it took over from a producer the +// operator unbound. Both are empty for a re-declaration. +type ModuleDeclareAuditEventTypesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AdmittedTypes []string `protobuf:"bytes,1,rep,name=admitted_types,json=admittedTypes,proto3" json:"admitted_types,omitempty"` + NamespacesTakenOver []string `protobuf:"bytes,2,rep,name=namespaces_taken_over,json=namespacesTakenOver,proto3" json:"namespaces_taken_over,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleDeclareAuditEventTypesResponse) Reset() { + *x = ModuleDeclareAuditEventTypesResponse{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleDeclareAuditEventTypesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleDeclareAuditEventTypesResponse) ProtoMessage() {} + +func (x *ModuleDeclareAuditEventTypesResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleDeclareAuditEventTypesResponse.ProtoReflect.Descriptor instead. +func (*ModuleDeclareAuditEventTypesResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{23} +} + +func (x *ModuleDeclareAuditEventTypesResponse) GetAdmittedTypes() []string { + if x != nil { + return x.AdmittedTypes + } + return nil +} + +func (x *ModuleDeclareAuditEventTypesResponse) GetNamespacesTakenOver() []string { + if x != nil { + return x.NamespacesTakenOver + } + return nil +} + +// SubjectVisibilityGrant is one entry of a viewer's visible-subject set: another +// subject whose rows the viewer may read, and the instant that permission ends. +// +// expires_at unset is an open-ended grant. A consuming module evaluates it at +// the moment of the read — an as-of read travels in data time and never +// restores the authority that held then — so the host writes the instant and +// never expires an entry on a timer. +type SubjectVisibilityGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + VisibleSubjectId string `protobuf:"bytes,1,opt,name=visible_subject_id,json=visibleSubjectId,proto3" json:"visible_subject_id,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubjectVisibilityGrant) Reset() { + *x = SubjectVisibilityGrant{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubjectVisibilityGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubjectVisibilityGrant) ProtoMessage() {} + +func (x *SubjectVisibilityGrant) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubjectVisibilityGrant.ProtoReflect.Descriptor instead. +func (*SubjectVisibilityGrant) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{24} +} + +func (x *SubjectVisibilityGrant) GetVisibleSubjectId() string { + if x != nil { + return x.VisibleSubjectId + } + return "" +} + +func (x *SubjectVisibilityGrant) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +// ModuleListSubjectVisibilityRequest asks for one viewer's whole visible-subject +// set in a tenant: the projection of that tenant's team tree onto the viewer. +// +// A module that enforces row visibility by owner holds no subject vocabulary and +// no hierarchy of its own — why one subject may see another's rows is a question +// about a hierarchy, and the hierarchy is the host's. viewer_subject_id is a +// host subject id, opaque to the module, which reads it and never interprets it. +// +// Authorization is the caller's registered module principal plus the tenant +// bound to it (or its cross-tenant grant), and the viewer must be a member of +// that tenant — the same guard NotifyUser and subject-scoped enqueue take. +type ModuleListSubjectVisibilityRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + ViewerSubjectId string `protobuf:"bytes,2,opt,name=viewer_subject_id,json=viewerSubjectId,proto3" json:"viewer_subject_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleListSubjectVisibilityRequest) Reset() { + *x = ModuleListSubjectVisibilityRequest{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleListSubjectVisibilityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleListSubjectVisibilityRequest) ProtoMessage() {} + +func (x *ModuleListSubjectVisibilityRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleListSubjectVisibilityRequest.ProtoReflect.Descriptor instead. +func (*ModuleListSubjectVisibilityRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{25} +} + +func (x *ModuleListSubjectVisibilityRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ModuleListSubjectVisibilityRequest) GetViewerSubjectId() string { + if x != nil { + return x.ViewerSubjectId + } + return "" +} + +// ModuleListSubjectVisibilityResponse carries the viewer's WHOLE set, read in +// one transaction. It is deliberately not paginated: the consuming operation is +// a bulk replace of a viewer's whole set, so a set assembled from pages read in +// separate transactions could carry a grant that was revoked between two of +// them, and the module would then reinstate an authority an administrator had +// already withdrawn. A tenant whose hierarchy makes one viewer's set exceed the +// server cap is refused with FailedPrecondition rather than answered with a +// torn set. +// +// The set never contains the viewer: seeing one's own rows is ownership, not a +// grant from the hierarchy, and it is the consuming module's own read predicate +// that admits it. Order is unspecified — this is a set, and the consumer +// replaces with all of it. +type ModuleListSubjectVisibilityResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Grants []*SubjectVisibilityGrant `protobuf:"bytes,1,rep,name=grants,proto3" json:"grants,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleListSubjectVisibilityResponse) Reset() { + *x = ModuleListSubjectVisibilityResponse{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleListSubjectVisibilityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleListSubjectVisibilityResponse) ProtoMessage() {} + +func (x *ModuleListSubjectVisibilityResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleListSubjectVisibilityResponse.ProtoReflect.Descriptor instead. +func (*ModuleListSubjectVisibilityResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{26} +} + +func (x *ModuleListSubjectVisibilityResponse) GetGrants() []*SubjectVisibilityGrant { + if x != nil { + return x.Grants + } + return nil +} + +// FetchDatasourceBlobRequest asks accounts to re-fetch one file blob from the +// upstream provider of a connected datasource, by the source that owns it and +// the blob's content-addressed sha. It is the module's fallback when a compiled +// change set carried a blob sha instead of inline content (a blob past the +// inline cap): the module resolves the bytes here rather than holding a GitHub +// token. Authorization is the caller's module principal (allowed to claim the +// datasource queue) plus the source row's own org and boundary — not the +// request tenant — so no per-blob ticket is needed. +type FetchDatasourceBlobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceId string `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + BlobSha string `protobuf:"bytes,2,opt,name=blob_sha,json=blobSha,proto3" json:"blob_sha,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FetchDatasourceBlobRequest) Reset() { + *x = FetchDatasourceBlobRequest{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FetchDatasourceBlobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FetchDatasourceBlobRequest) ProtoMessage() {} + +func (x *FetchDatasourceBlobRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FetchDatasourceBlobRequest.ProtoReflect.Descriptor instead. +func (*FetchDatasourceBlobRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{27} +} + +func (x *FetchDatasourceBlobRequest) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *FetchDatasourceBlobRequest) GetBlobSha() string { + if x != nil { + return x.BlobSha + } + return "" +} + +// FetchDatasourceBlobChunk is one frame of the streamed blob. total_size and +// content_type are set on every chunk (the stream carries no separate header +// frame) and stay constant across the stream; data is the next slice of bytes. +// A blob past the 25 MiB cap is refused before the first chunk. +type FetchDatasourceBlobChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + TotalSize int64 `protobuf:"varint,2,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` + ContentType string `protobuf:"bytes,3,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FetchDatasourceBlobChunk) Reset() { + *x = FetchDatasourceBlobChunk{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FetchDatasourceBlobChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FetchDatasourceBlobChunk) ProtoMessage() {} + +func (x *FetchDatasourceBlobChunk) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FetchDatasourceBlobChunk.ProtoReflect.Descriptor instead. +func (*FetchDatasourceBlobChunk) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{28} +} + +func (x *FetchDatasourceBlobChunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *FetchDatasourceBlobChunk) GetTotalSize() int64 { + if x != nil { + return x.TotalSize + } + return 0 +} + +func (x *FetchDatasourceBlobChunk) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +// DatasourceFileRef names one file of a files-interface datasource at the +// version its request pins: its stable provider id (for a GitHub source, its +// path) and the version of its content it was listed with (for a GitHub +// source, its git blob id). +type DatasourceFileRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + ItemId string `protobuf:"bytes,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"` + ItemVersion string `protobuf:"bytes,2,opt,name=item_version,json=itemVersion,proto3" json:"item_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DatasourceFileRef) Reset() { + *x = DatasourceFileRef{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DatasourceFileRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DatasourceFileRef) ProtoMessage() {} + +func (x *DatasourceFileRef) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DatasourceFileRef.ProtoReflect.Descriptor instead. +func (*DatasourceFileRef) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{29} +} + +func (x *DatasourceFileRef) GetItemId() string { + if x != nil { + return x.ItemId + } + return "" +} + +func (x *DatasourceFileRef) GetItemVersion() string { + if x != nil { + return x.ItemVersion + } + return "" +} + +// FetchDatasourceFilesRequest asks accounts for a batch of files of one source +// at one pinned version, in a single call. version is the provider's opaque +// version the files were listed at: for a GitHub source, the commit a snapshot +// manifest or change set names. Every named file must be in the source's scope +// (its path prefixes and file types) at that version, with the item version the +// source holds for it there; a request naming anything else is refused whole, +// before the first frame. At most 1000 files and 64 MiB of content are +// served per call, and at most 25 MiB per file. +type FetchDatasourceFilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceId string `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Files []*DatasourceFileRef `protobuf:"bytes,3,rep,name=files,proto3" json:"files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FetchDatasourceFilesRequest) Reset() { + *x = FetchDatasourceFilesRequest{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FetchDatasourceFilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FetchDatasourceFilesRequest) ProtoMessage() {} + +func (x *FetchDatasourceFilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FetchDatasourceFilesRequest.ProtoReflect.Descriptor instead. +func (*FetchDatasourceFilesRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{30} +} + +func (x *FetchDatasourceFilesRequest) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *FetchDatasourceFilesRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *FetchDatasourceFilesRequest) GetFiles() []*DatasourceFileRef { + if x != nil { + return x.Files + } + return nil +} + +// DatasourceProvenance is the envelope every item a datasource serves carries, +// whichever interface serves it: the source, the org and boundary that own it, +// the provider version the source was read at, the provider's stable id for +// the item, and the version of the item's content. +type DatasourceProvenance struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceId string `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + OrgId string `protobuf:"bytes,2,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + BoundaryNodeId string `protobuf:"bytes,3,opt,name=boundary_node_id,json=boundaryNodeId,proto3" json:"boundary_node_id,omitempty"` + Version string `protobuf:"bytes,4,opt,name=version,proto3" json:"version,omitempty"` + ItemId string `protobuf:"bytes,5,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"` + ItemVersion string `protobuf:"bytes,6,opt,name=item_version,json=itemVersion,proto3" json:"item_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DatasourceProvenance) Reset() { + *x = DatasourceProvenance{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DatasourceProvenance) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DatasourceProvenance) ProtoMessage() {} + +func (x *DatasourceProvenance) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DatasourceProvenance.ProtoReflect.Descriptor instead. +func (*DatasourceProvenance) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{31} +} + +func (x *DatasourceProvenance) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *DatasourceProvenance) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *DatasourceProvenance) GetBoundaryNodeId() string { + if x != nil { + return x.BoundaryNodeId + } + return "" +} + +func (x *DatasourceProvenance) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *DatasourceProvenance) GetItemId() string { + if x != nil { + return x.ItemId + } + return "" +} + +func (x *DatasourceProvenance) GetItemVersion() string { + if x != nil { + return x.ItemVersion + } + return "" +} + +// DatasourceItemReaders is the envelope's per-item permission slot: who may read +// the item, as the host derived it. A files-interface GitHub source has no +// per-file access list, so every file it serves is SOURCE_SCOPED with +// boundary_readers set and nothing else. +type DatasourceItemReaders struct { + state protoimpl.MessageState `protogen:"open.v1"` + Basis DatasourceItemReadersBasis `protobuf:"varint,1,opt,name=basis,proto3,enum=saas.accounts.v1.DatasourceItemReadersBasis" json:"basis,omitempty"` + UserIds []string `protobuf:"bytes,2,rep,name=user_ids,json=userIds,proto3" json:"user_ids,omitempty"` + GroupIds []string `protobuf:"bytes,3,rep,name=group_ids,json=groupIds,proto3" json:"group_ids,omitempty"` + // boundary_readers grants the item to whoever may read the source's boundary. + BoundaryReaders bool `protobuf:"varint,4,opt,name=boundary_readers,json=boundaryReaders,proto3" json:"boundary_readers,omitempty"` + // unmapped_count counts provider principals the translation could not map. + UnmappedCount int32 `protobuf:"varint,5,opt,name=unmapped_count,json=unmappedCount,proto3" json:"unmapped_count,omitempty"` + // acl_version is the provider's version of the access list, when it has one. + AclVersion string `protobuf:"bytes,6,opt,name=acl_version,json=aclVersion,proto3" json:"acl_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DatasourceItemReaders) Reset() { + *x = DatasourceItemReaders{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DatasourceItemReaders) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DatasourceItemReaders) ProtoMessage() {} + +func (x *DatasourceItemReaders) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1372,91 +2594,82 @@ func (x *ModuleEmitAuditEventRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ModuleEmitAuditEventRequest.ProtoReflect.Descriptor instead. -func (*ModuleEmitAuditEventRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{17} -} - -func (x *ModuleEmitAuditEventRequest) GetTenant() string { - if x != nil { - return x.Tenant - } - return "" +// Deprecated: Use DatasourceItemReaders.ProtoReflect.Descriptor instead. +func (*DatasourceItemReaders) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{32} } -func (x *ModuleEmitAuditEventRequest) GetEventType() string { +func (x *DatasourceItemReaders) GetBasis() DatasourceItemReadersBasis { if x != nil { - return x.EventType + return x.Basis } - return "" + return DatasourceItemReadersBasis_DATASOURCE_ITEM_READERS_BASIS_UNSPECIFIED } -func (x *ModuleEmitAuditEventRequest) GetActor() string { +func (x *DatasourceItemReaders) GetUserIds() []string { if x != nil { - return x.Actor + return x.UserIds } - return "" + return nil } -func (x *ModuleEmitAuditEventRequest) GetSolution() string { +func (x *DatasourceItemReaders) GetGroupIds() []string { if x != nil { - return x.Solution + return x.GroupIds } - return "" + return nil } -func (x *ModuleEmitAuditEventRequest) GetEntryId() string { +func (x *DatasourceItemReaders) GetBoundaryReaders() bool { if x != nil { - return x.EntryId + return x.BoundaryReaders } - return "" + return false } -func (x *ModuleEmitAuditEventRequest) GetFields() *structpb.Struct { +func (x *DatasourceItemReaders) GetUnmappedCount() int32 { if x != nil { - return x.Fields + return x.UnmappedCount } - return nil + return 0 } -func (x *ModuleEmitAuditEventRequest) GetIdempotencyKey() string { +func (x *DatasourceItemReaders) GetAclVersion() string { if x != nil { - return x.IdempotencyKey + return x.AclVersion } return "" } -// FetchDatasourceBlobRequest asks accounts to re-fetch one file blob from the -// upstream provider of a connected datasource, by the source that owns it and -// the blob's content-addressed sha. It is the module's fallback when a compiled -// change set carried a blob sha instead of inline content (a blob past the -// inline cap): the module resolves the bytes here rather than holding a GitHub -// token. Authorization is the caller's module principal (allowed to claim the -// datasource queue) plus the source row's own org and boundary — not the -// request tenant — so no per-blob ticket is needed. -type FetchDatasourceBlobRequest struct { +// DatasourceFileHeader opens one file of a FetchDatasourceFiles stream. size is +// the exact byte count the data frames that follow deliver; content_type is the +// host's detection from the bytes, advisory only. +type DatasourceFileHeader struct { state protoimpl.MessageState `protogen:"open.v1"` - SourceId string `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` - BlobSha string `protobuf:"bytes,2,opt,name=blob_sha,json=blobSha,proto3" json:"blob_sha,omitempty"` + Provenance *DatasourceProvenance `protobuf:"bytes,1,opt,name=provenance,proto3" json:"provenance,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + ContentType string `protobuf:"bytes,3,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Readers *DatasourceItemReaders `protobuf:"bytes,5,opt,name=readers,proto3" json:"readers,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *FetchDatasourceBlobRequest) Reset() { - *x = FetchDatasourceBlobRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[18] +func (x *DatasourceFileHeader) Reset() { + *x = DatasourceFileHeader{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *FetchDatasourceBlobRequest) String() string { +func (x *DatasourceFileHeader) String() string { return protoimpl.X.MessageStringOf(x) } -func (*FetchDatasourceBlobRequest) ProtoMessage() {} +func (*DatasourceFileHeader) ProtoMessage() {} -func (x *FetchDatasourceBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[18] +func (x *DatasourceFileHeader) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1467,53 +2680,75 @@ func (x *FetchDatasourceBlobRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use FetchDatasourceBlobRequest.ProtoReflect.Descriptor instead. -func (*FetchDatasourceBlobRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{18} +// Deprecated: Use DatasourceFileHeader.ProtoReflect.Descriptor instead. +func (*DatasourceFileHeader) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{33} } -func (x *FetchDatasourceBlobRequest) GetSourceId() string { +func (x *DatasourceFileHeader) GetProvenance() *DatasourceProvenance { if x != nil { - return x.SourceId + return x.Provenance + } + return nil +} + +func (x *DatasourceFileHeader) GetPath() string { + if x != nil { + return x.Path } return "" } -func (x *FetchDatasourceBlobRequest) GetBlobSha() string { +func (x *DatasourceFileHeader) GetContentType() string { if x != nil { - return x.BlobSha + return x.ContentType } return "" } -// FetchDatasourceBlobChunk is one frame of the streamed blob. total_size and -// content_type are set on every chunk (the stream carries no separate header -// frame) and stay constant across the stream; data is the next slice of bytes. -// A blob past the 25 MiB cap is refused before the first chunk. -type FetchDatasourceBlobChunk struct { - state protoimpl.MessageState `protogen:"open.v1"` - Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` - TotalSize int64 `protobuf:"varint,2,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` - ContentType string `protobuf:"bytes,3,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` +func (x *DatasourceFileHeader) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *DatasourceFileHeader) GetReaders() *DatasourceItemReaders { + if x != nil { + return x.Readers + } + return nil +} + +// FetchDatasourceFilesFrame is one frame of a FetchDatasourceFiles stream. The +// files arrive in request order; each is one header frame followed by data +// frames until the header's size has been delivered (an empty file has none). +type FetchDatasourceFilesFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *FetchDatasourceFilesFrame_Header + // *FetchDatasourceFilesFrame_Data + Frame isFetchDatasourceFilesFrame_Frame `protobuf_oneof:"frame"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *FetchDatasourceBlobChunk) Reset() { - *x = FetchDatasourceBlobChunk{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[19] +func (x *FetchDatasourceFilesFrame) Reset() { + *x = FetchDatasourceFilesFrame{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *FetchDatasourceBlobChunk) String() string { +func (x *FetchDatasourceFilesFrame) String() string { return protoimpl.X.MessageStringOf(x) } -func (*FetchDatasourceBlobChunk) ProtoMessage() {} +func (*FetchDatasourceFilesFrame) ProtoMessage() {} -func (x *FetchDatasourceBlobChunk) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[19] +func (x *FetchDatasourceFilesFrame) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1524,32 +2759,52 @@ func (x *FetchDatasourceBlobChunk) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use FetchDatasourceBlobChunk.ProtoReflect.Descriptor instead. -func (*FetchDatasourceBlobChunk) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{19} +// Deprecated: Use FetchDatasourceFilesFrame.ProtoReflect.Descriptor instead. +func (*FetchDatasourceFilesFrame) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{34} } -func (x *FetchDatasourceBlobChunk) GetData() []byte { +func (x *FetchDatasourceFilesFrame) GetFrame() isFetchDatasourceFilesFrame_Frame { if x != nil { - return x.Data + return x.Frame } return nil } -func (x *FetchDatasourceBlobChunk) GetTotalSize() int64 { +func (x *FetchDatasourceFilesFrame) GetHeader() *DatasourceFileHeader { if x != nil { - return x.TotalSize + if x, ok := x.Frame.(*FetchDatasourceFilesFrame_Header); ok { + return x.Header + } } - return 0 + return nil } -func (x *FetchDatasourceBlobChunk) GetContentType() string { +func (x *FetchDatasourceFilesFrame) GetData() []byte { if x != nil { - return x.ContentType + if x, ok := x.Frame.(*FetchDatasourceFilesFrame_Data); ok { + return x.Data + } } - return "" + return nil +} + +type isFetchDatasourceFilesFrame_Frame interface { + isFetchDatasourceFilesFrame_Frame() +} + +type FetchDatasourceFilesFrame_Header struct { + Header *DatasourceFileHeader `protobuf:"bytes,1,opt,name=header,proto3,oneof"` +} + +type FetchDatasourceFilesFrame_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` } +func (*FetchDatasourceFilesFrame_Header) isFetchDatasourceFilesFrame_Frame() {} + +func (*FetchDatasourceFilesFrame_Data) isFetchDatasourceFilesFrame_Frame() {} + // ModulePublishEventRequest publishes one domain event for the caller's bound // tenant. The event type's namespace (the segment before the first dot) must be // one the caller's principal declares. The envelope id is the idempotency key: @@ -1565,7 +2820,7 @@ type ModulePublishEventRequest struct { func (x *ModulePublishEventRequest) Reset() { *x = ModulePublishEventRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[20] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1577,7 +2832,7 @@ func (x *ModulePublishEventRequest) String() string { func (*ModulePublishEventRequest) ProtoMessage() {} func (x *ModulePublishEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[20] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1590,7 +2845,7 @@ func (x *ModulePublishEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModulePublishEventRequest.ProtoReflect.Descriptor instead. func (*ModulePublishEventRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{20} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{35} } func (x *ModulePublishEventRequest) GetTenant() string { @@ -1620,7 +2875,7 @@ type ModulePublishEventResponse struct { func (x *ModulePublishEventResponse) Reset() { *x = ModulePublishEventResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[21] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1632,7 +2887,7 @@ func (x *ModulePublishEventResponse) String() string { func (*ModulePublishEventResponse) ProtoMessage() {} func (x *ModulePublishEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[21] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1645,7 +2900,7 @@ func (x *ModulePublishEventResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ModulePublishEventResponse.ProtoReflect.Descriptor instead. func (*ModulePublishEventResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{21} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{36} } func (x *ModulePublishEventResponse) GetEventId() string { @@ -1671,7 +2926,7 @@ type ModuleSubscribeRequest struct { func (x *ModuleSubscribeRequest) Reset() { *x = ModuleSubscribeRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[22] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1683,7 +2938,7 @@ func (x *ModuleSubscribeRequest) String() string { func (*ModuleSubscribeRequest) ProtoMessage() {} func (x *ModuleSubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[22] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1696,7 +2951,7 @@ func (x *ModuleSubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleSubscribeRequest.ProtoReflect.Descriptor instead. func (*ModuleSubscribeRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{22} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{37} } func (x *ModuleSubscribeRequest) GetTypePattern() string { @@ -1735,7 +2990,7 @@ type ModuleSubscription struct { func (x *ModuleSubscription) Reset() { *x = ModuleSubscription{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[23] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1747,7 +3002,7 @@ func (x *ModuleSubscription) String() string { func (*ModuleSubscription) ProtoMessage() {} func (x *ModuleSubscription) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[23] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1760,7 +3015,7 @@ func (x *ModuleSubscription) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleSubscription.ProtoReflect.Descriptor instead. func (*ModuleSubscription) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{23} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{38} } func (x *ModuleSubscription) GetId() string { @@ -1814,7 +3069,7 @@ type ModuleSubscribeResponse struct { func (x *ModuleSubscribeResponse) Reset() { *x = ModuleSubscribeResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[24] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1826,7 +3081,7 @@ func (x *ModuleSubscribeResponse) String() string { func (*ModuleSubscribeResponse) ProtoMessage() {} func (x *ModuleSubscribeResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[24] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1839,7 +3094,7 @@ func (x *ModuleSubscribeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleSubscribeResponse.ProtoReflect.Descriptor instead. func (*ModuleSubscribeResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{24} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{39} } func (x *ModuleSubscribeResponse) GetSubscription() *ModuleSubscription { @@ -1859,7 +3114,7 @@ type ModuleUnsubscribeRequest struct { func (x *ModuleUnsubscribeRequest) Reset() { *x = ModuleUnsubscribeRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[25] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1871,7 +3126,7 @@ func (x *ModuleUnsubscribeRequest) String() string { func (*ModuleUnsubscribeRequest) ProtoMessage() {} func (x *ModuleUnsubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[25] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1884,7 +3139,7 @@ func (x *ModuleUnsubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleUnsubscribeRequest.ProtoReflect.Descriptor instead. func (*ModuleUnsubscribeRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{25} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{40} } func (x *ModuleUnsubscribeRequest) GetSubscriptionId() string { @@ -1904,7 +3159,7 @@ type ModuleListSubscriptionsRequest struct { func (x *ModuleListSubscriptionsRequest) Reset() { *x = ModuleListSubscriptionsRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[26] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1916,7 +3171,7 @@ func (x *ModuleListSubscriptionsRequest) String() string { func (*ModuleListSubscriptionsRequest) ProtoMessage() {} func (x *ModuleListSubscriptionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[26] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1929,7 +3184,7 @@ func (x *ModuleListSubscriptionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleListSubscriptionsRequest.ProtoReflect.Descriptor instead. func (*ModuleListSubscriptionsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{26} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{41} } type ModuleListSubscriptionsResponse struct { @@ -1941,7 +3196,7 @@ type ModuleListSubscriptionsResponse struct { func (x *ModuleListSubscriptionsResponse) Reset() { *x = ModuleListSubscriptionsResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[27] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1953,7 +3208,7 @@ func (x *ModuleListSubscriptionsResponse) String() string { func (*ModuleListSubscriptionsResponse) ProtoMessage() {} func (x *ModuleListSubscriptionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[27] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1966,7 +3221,7 @@ func (x *ModuleListSubscriptionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleListSubscriptionsResponse.ProtoReflect.Descriptor instead. func (*ModuleListSubscriptionsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{27} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{42} } func (x *ModuleListSubscriptionsResponse) GetSubscriptions() []*ModuleSubscription { @@ -1991,7 +3246,7 @@ type ModuleReplayEventsRequest struct { func (x *ModuleReplayEventsRequest) Reset() { *x = ModuleReplayEventsRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[28] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2003,7 +3258,7 @@ func (x *ModuleReplayEventsRequest) String() string { func (*ModuleReplayEventsRequest) ProtoMessage() {} func (x *ModuleReplayEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[28] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2016,7 +3271,7 @@ func (x *ModuleReplayEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleReplayEventsRequest.ProtoReflect.Descriptor instead. func (*ModuleReplayEventsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{28} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{43} } func (x *ModuleReplayEventsRequest) GetTenant() string { @@ -2051,7 +3306,7 @@ type ModuleReplayEventsResponse struct { func (x *ModuleReplayEventsResponse) Reset() { *x = ModuleReplayEventsResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[29] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2063,7 +3318,7 @@ func (x *ModuleReplayEventsResponse) String() string { func (*ModuleReplayEventsResponse) ProtoMessage() {} func (x *ModuleReplayEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[29] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2076,7 +3331,7 @@ func (x *ModuleReplayEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleReplayEventsResponse.ProtoReflect.Descriptor instead. func (*ModuleReplayEventsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{29} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{44} } func (x *ModuleReplayEventsResponse) GetRedelivered() int32 { @@ -2120,7 +3375,7 @@ type ReadableSourceCollection struct { func (x *ReadableSourceCollection) Reset() { *x = ReadableSourceCollection{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[30] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2132,7 +3387,7 @@ func (x *ReadableSourceCollection) String() string { func (*ReadableSourceCollection) ProtoMessage() {} func (x *ReadableSourceCollection) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[30] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2145,7 +3400,7 @@ func (x *ReadableSourceCollection) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadableSourceCollection.ProtoReflect.Descriptor instead. func (*ReadableSourceCollection) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{30} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{45} } func (x *ReadableSourceCollection) GetSourceId() string { @@ -2239,7 +3494,7 @@ type ReadableCollectionGrant struct { func (x *ReadableCollectionGrant) Reset() { *x = ReadableCollectionGrant{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[31] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2251,7 +3506,7 @@ func (x *ReadableCollectionGrant) String() string { func (*ReadableCollectionGrant) ProtoMessage() {} func (x *ReadableCollectionGrant) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[31] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2264,7 +3519,7 @@ func (x *ReadableCollectionGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadableCollectionGrant.ProtoReflect.Descriptor instead. func (*ReadableCollectionGrant) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{31} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{46} } func (x *ReadableCollectionGrant) GetSubjectLabel() string { @@ -2337,7 +3592,7 @@ type CollectionSyncProvenance struct { func (x *CollectionSyncProvenance) Reset() { *x = CollectionSyncProvenance{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[32] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2349,7 +3604,7 @@ func (x *CollectionSyncProvenance) String() string { func (*CollectionSyncProvenance) ProtoMessage() {} func (x *CollectionSyncProvenance) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[32] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2362,7 +3617,7 @@ func (x *CollectionSyncProvenance) ProtoReflect() protoreflect.Message { // Deprecated: Use CollectionSyncProvenance.ProtoReflect.Descriptor instead. func (*CollectionSyncProvenance) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{32} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{47} } func (x *CollectionSyncProvenance) GetStage() SourceSyncStage { @@ -2425,7 +3680,7 @@ type ListReadableSourceCollectionsRequest struct { func (x *ListReadableSourceCollectionsRequest) Reset() { *x = ListReadableSourceCollectionsRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[33] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2437,7 +3692,7 @@ func (x *ListReadableSourceCollectionsRequest) String() string { func (*ListReadableSourceCollectionsRequest) ProtoMessage() {} func (x *ListReadableSourceCollectionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[33] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2450,7 +3705,7 @@ func (x *ListReadableSourceCollectionsRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use ListReadableSourceCollectionsRequest.ProtoReflect.Descriptor instead. func (*ListReadableSourceCollectionsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{33} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{48} } func (x *ListReadableSourceCollectionsRequest) GetPageSize() int32 { @@ -2477,7 +3732,7 @@ type ListReadableSourceCollectionsResponse struct { func (x *ListReadableSourceCollectionsResponse) Reset() { *x = ListReadableSourceCollectionsResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[34] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2489,7 +3744,7 @@ func (x *ListReadableSourceCollectionsResponse) String() string { func (*ListReadableSourceCollectionsResponse) ProtoMessage() {} func (x *ListReadableSourceCollectionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[34] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2502,7 +3757,7 @@ func (x *ListReadableSourceCollectionsResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use ListReadableSourceCollectionsResponse.ProtoReflect.Descriptor instead. func (*ListReadableSourceCollectionsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{34} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{49} } func (x *ListReadableSourceCollectionsResponse) GetCollections() []*ReadableSourceCollection { @@ -2546,7 +3801,7 @@ type ModulePlaceRecordRequest struct { func (x *ModulePlaceRecordRequest) Reset() { *x = ModulePlaceRecordRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[35] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2558,7 +3813,7 @@ func (x *ModulePlaceRecordRequest) String() string { func (*ModulePlaceRecordRequest) ProtoMessage() {} func (x *ModulePlaceRecordRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[35] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2571,7 +3826,7 @@ func (x *ModulePlaceRecordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ModulePlaceRecordRequest.ProtoReflect.Descriptor instead. func (*ModulePlaceRecordRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{35} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{50} } func (x *ModulePlaceRecordRequest) GetTenant() string { @@ -2630,7 +3885,7 @@ type ModulePlaceRecordResponse struct { func (x *ModulePlaceRecordResponse) Reset() { *x = ModulePlaceRecordResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[36] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2642,7 +3897,7 @@ func (x *ModulePlaceRecordResponse) String() string { func (*ModulePlaceRecordResponse) ProtoMessage() {} func (x *ModulePlaceRecordResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[36] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2655,7 +3910,7 @@ func (x *ModulePlaceRecordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ModulePlaceRecordResponse.ProtoReflect.Descriptor instead. func (*ModulePlaceRecordResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{36} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{51} } func (x *ModulePlaceRecordResponse) GetNodeId() string { @@ -2678,7 +3933,7 @@ type CheckWorkContextRecordAccessRequest struct { func (x *CheckWorkContextRecordAccessRequest) Reset() { *x = CheckWorkContextRecordAccessRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[37] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2690,7 +3945,7 @@ func (x *CheckWorkContextRecordAccessRequest) String() string { func (*CheckWorkContextRecordAccessRequest) ProtoMessage() {} func (x *CheckWorkContextRecordAccessRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[37] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2703,7 +3958,7 @@ func (x *CheckWorkContextRecordAccessRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use CheckWorkContextRecordAccessRequest.ProtoReflect.Descriptor instead. func (*CheckWorkContextRecordAccessRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{37} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{52} } func (x *CheckWorkContextRecordAccessRequest) GetResourceType() string { @@ -2739,7 +3994,7 @@ type CheckWorkContextRecordAccessResponse struct { func (x *CheckWorkContextRecordAccessResponse) Reset() { *x = CheckWorkContextRecordAccessResponse{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[38] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2751,7 +4006,7 @@ func (x *CheckWorkContextRecordAccessResponse) String() string { func (*CheckWorkContextRecordAccessResponse) ProtoMessage() {} func (x *CheckWorkContextRecordAccessResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[38] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2764,7 +4019,7 @@ func (x *CheckWorkContextRecordAccessResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use CheckWorkContextRecordAccessResponse.ProtoReflect.Descriptor instead. func (*CheckWorkContextRecordAccessResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{38} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{53} } func (x *CheckWorkContextRecordAccessResponse) GetAllowed() bool { @@ -2793,7 +4048,7 @@ type ModuleExchangeDelegatedReadAudienceRequest struct { func (x *ModuleExchangeDelegatedReadAudienceRequest) Reset() { *x = ModuleExchangeDelegatedReadAudienceRequest{} - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[39] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2805,7 +4060,7 @@ func (x *ModuleExchangeDelegatedReadAudienceRequest) String() string { func (*ModuleExchangeDelegatedReadAudienceRequest) ProtoMessage() {} func (x *ModuleExchangeDelegatedReadAudienceRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[39] + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2818,7 +4073,7 @@ func (x *ModuleExchangeDelegatedReadAudienceRequest) ProtoReflect() protoreflect // Deprecated: Use ModuleExchangeDelegatedReadAudienceRequest.ProtoReflect.Descriptor instead. func (*ModuleExchangeDelegatedReadAudienceRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{39} + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{54} } func (x *ModuleExchangeDelegatedReadAudienceRequest) GetBindingId() string { @@ -2835,6 +4090,101 @@ func (x *ModuleExchangeDelegatedReadAudienceRequest) GetParentWorkContextToken() return "" } +// Selects one immutable installed operation binding. The caller chooses only +// invocation versus receipt lookup; audience, scopes and lifetime are policy. +// +// The caller presents its authority one of two ways, and exactly one: +// +// - parent_work_context_token, a live capability it already holds. The child +// is attenuated against that parent and expires with it, so work that +// outlives the parent cannot be authorized this way at all. +// - delegation_id, a reference to a host-owned, revocable source delegation. +// No capability is presented, none is held, and none needs to still be +// valid: the host re-checks the delegation live and mints a fresh short +// child from it. This is how work longer than any Work Context stays +// authorized — the caller holds the reference, never a token — and how it +// renews, by exchanging again against the same reference. A revocation +// takes effect on the next exchange. +// +// Not a oneof, though the two are exclusive and it is the shape this would +// otherwise take. Moving field 2 into one changes its presence and is a +// breaking contract change (`buf breaking`), which this surface does not get to +// make to a published field. The exclusivity is enforced here instead, so it is +// still the contract and not a convention: a request setting both, or neither, +// is INVALID_ARGUMENT before any handler sees it. +type ModuleExchangeDelegatedOperationAudienceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + ParentWorkContextToken string `protobuf:"bytes,2,opt,name=parent_work_context_token,json=parentWorkContextToken,proto3" json:"parent_work_context_token,omitempty"` + Lookup bool `protobuf:"varint,3,opt,name=lookup,proto3" json:"lookup,omitempty"` + // delegation_id names one source delegation. It must be the delegation a + // person made to the module whose installed binding names this caller as its + // audience; one belonging to another module, one this caller is not the + // audience of, and one that is revoked or does not exist are all refused with + // PERMISSION_DENIED, indistinguishably. Empty means the parent arm is in use. + DelegationId string `protobuf:"bytes,4,opt,name=delegation_id,json=delegationId,proto3" json:"delegation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) Reset() { + *x = ModuleExchangeDelegatedOperationAudienceRequest{} + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleExchangeDelegatedOperationAudienceRequest) ProtoMessage() {} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_capabilities_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleExchangeDelegatedOperationAudienceRequest.ProtoReflect.Descriptor instead. +func (*ModuleExchangeDelegatedOperationAudienceRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP(), []int{55} +} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) GetBindingId() string { + if x != nil { + return x.BindingId + } + return "" +} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) GetParentWorkContextToken() string { + if x != nil { + return x.ParentWorkContextToken + } + return "" +} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) GetLookup() bool { + if x != nil { + return x.Lookup + } + return false +} + +func (x *ModuleExchangeDelegatedOperationAudienceRequest) GetDelegationId() string { + if x != nil { + return x.DelegationId + } + return "" +} + var File_saas_accounts_v1_module_capabilities_proto protoreflect.FileDescriptor const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + @@ -2881,7 +4231,19 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\x0fidempotency_key\x18\b \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x0eidempotencyKey\"a\n" + "\x18ModuleNotifyUserResponse\x12'\n" + "\x0fnotification_id\x18\x01 \x01(\tR\x0enotificationId\x12\x1c\n" + - "\tdelivered\x18\x02 \x01(\bR\tdelivered\"\x83\x01\n" + + "\tdelivered\x18\x02 \x01(\bR\tdelivered\"\xa0\x02\n" + + "\x1cModuleNotifyOrgAdminsRequest\x12 \n" + + "\x06tenant\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06tenant\x12 \n" + + "\x05title\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xe6\aR\x05title\x12\x1c\n" + + "\x04body\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\x80@R\x04body\x12\x1b\n" + + "\x04type\x18\x04 \x01(\tB\a\xbaH\x04r\x02\x18@R\x04type\x12'\n" + + "\n" + + "action_url\x18\x05 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x10R\tactionUrl\x12%\n" + + "\bcategory\x18\x06 \x01(\tB\t\xbaH\x06r\x04\x10\x01\x18 R\bcategory\x121\n" + + "\x0fidempotency_key\x18\a \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x0eidempotencyKey\"=\n" + + "\x1dModuleNotifyOrgAdminsResponse\x12\x1c\n" + + "\tdelivered\x18\x01 \x01(\bR\tdelivered\"\x83\x01\n" + "\x14ModuleApprovalPolicy\x12\x1f\n" + "\x06quorum\x18\x01 \x01(\rB\a\xbaH\x04*\x02\x18dR\x06quorum\x12+\n" + "\fapprover_set\x18\x02 \x03(\tB\b\xbaH\x05\x92\x01\x02\x10dR\vapproverSet\x12\x1d\n" + @@ -2948,7 +4310,37 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\bsolution\x12#\n" + "\bentry_id\x18\x05 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\aentryId\x12/\n" + "\x06fields\x18\x06 \x01(\v2\x17.google.protobuf.StructR\x06fields\x121\n" + - "\x0fidempotency_key\x18\a \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x0eidempotencyKey\"j\n" + + "\x0fidempotency_key\x18\a \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x0eidempotencyKey\"\xb8\x01\n" + + "\x1bModuleAuditFieldDeclaration\x12\x1d\n" + + "\x04name\x18\x01 \x01(\tB\t\xbaH\x06r\x04\x10\x01\x18@R\x04name\x12F\n" + + "\x04kind\x18\x02 \x01(\x0e2&.saas.accounts.v1.ModuleAuditFieldKindB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x04kind\x12 \n" + + "\x06values\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x10@R\x06values\x12\x10\n" + + "\x03pii\x18\x04 \x01(\bR\x03pii\"\x96\x02\n" + + "\x1fModuleAuditEventTypeDeclaration\x12\x1e\n" + + "\x04type\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\x04type\x12*\n" + + "\vdescription\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\x80\bR\vdescription\x12O\n" + + "\x06fields\x18\x03 \x03(\v2-.saas.accounts.v1.ModuleAuditFieldDeclarationB\b\xbaH\x05\x92\x01\x02\x10 R\x06fields\x12V\n" + + "\n" + + "visibility\x18\x04 \x01(\x0e2,.saas.accounts.v1.ModuleAuditEventVisibilityB\b\xbaH\x05\x82\x01\x02\x10\x01R\n" + + "visibility\"\xc2\x01\n" + + "#ModuleDeclareAuditEventTypesRequest\x12F\n" + + "\x06prefix\x18\x01 \x01(\tB.\xbaH+r)\x10\x01\x18\x80\x012\"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$R\x06prefix\x12S\n" + + "\x05types\x18\x02 \x03(\v21.saas.accounts.v1.ModuleAuditEventTypeDeclarationB\n" + + "\xbaH\a\x92\x01\x04\b\x01\x10@R\x05types\"\x81\x01\n" + + "$ModuleDeclareAuditEventTypesResponse\x12%\n" + + "\x0eadmitted_types\x18\x01 \x03(\tR\radmittedTypes\x122\n" + + "\x15namespaces_taken_over\x18\x02 \x03(\tR\x13namespacesTakenOver\"\x8a\x01\n" + + "\x16SubjectVisibilityGrant\x125\n" + + "\x12visible_subject_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x10visibleSubjectId\x129\n" + + "\n" + + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"|\n" + + "\"ModuleListSubjectVisibilityRequest\x12 \n" + + "\x06tenant\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06tenant\x124\n" + + "\x11viewer_subject_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0fviewerSubjectId\"g\n" + + "#ModuleListSubjectVisibilityResponse\x12@\n" + + "\x06grants\x18\x01 \x03(\v2(.saas.accounts.v1.SubjectVisibilityGrantR\x06grants\"j\n" + "\x1aFetchDatasourceBlobRequest\x12%\n" + "\tsource_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\bsourceId\x12%\n" + "\bblob_sha\x18\x02 \x01(\tB\n" + @@ -2957,7 +4349,44 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\x04data\x18\x01 \x01(\fR\x04data\x12\x1d\n" + "\n" + "total_size\x18\x02 \x01(\x03R\ttotalSize\x12!\n" + - "\fcontent_type\x18\x03 \x01(\tR\vcontentType\"\x80\x01\n" + + "\fcontent_type\x18\x03 \x01(\tR\vcontentType\"g\n" + + "\x11DatasourceFileRef\x12#\n" + + "\aitem_id\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80 R\x06itemId\x12-\n" + + "\fitem_version\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\vitemVersion\"\xb2\x01\n" + + "\x1bFetchDatasourceFilesRequest\x12%\n" + + "\tsource_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\bsourceId\x12$\n" + + "\aversion\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\aversion\x12F\n" + + "\x05files\x18\x03 \x03(\v2#.saas.accounts.v1.DatasourceFileRefB\v\xbaH\b\x92\x01\x05\b\x01\x10\xe8\aR\x05files\"\xca\x01\n" + + "\x14DatasourceProvenance\x12\x1b\n" + + "\tsource_id\x18\x01 \x01(\tR\bsourceId\x12\x15\n" + + "\x06org_id\x18\x02 \x01(\tR\x05orgId\x12(\n" + + "\x10boundary_node_id\x18\x03 \x01(\tR\x0eboundaryNodeId\x12\x18\n" + + "\aversion\x18\x04 \x01(\tR\aversion\x12\x17\n" + + "\aitem_id\x18\x05 \x01(\tR\x06itemId\x12!\n" + + "\fitem_version\x18\x06 \x01(\tR\vitemVersion\"\x86\x02\n" + + "\x15DatasourceItemReaders\x12B\n" + + "\x05basis\x18\x01 \x01(\x0e2,.saas.accounts.v1.DatasourceItemReadersBasisR\x05basis\x12\x19\n" + + "\buser_ids\x18\x02 \x03(\tR\auserIds\x12\x1b\n" + + "\tgroup_ids\x18\x03 \x03(\tR\bgroupIds\x12)\n" + + "\x10boundary_readers\x18\x04 \x01(\bR\x0fboundaryReaders\x12%\n" + + "\x0eunmapped_count\x18\x05 \x01(\x05R\runmappedCount\x12\x1f\n" + + "\vacl_version\x18\x06 \x01(\tR\n" + + "aclVersion\"\xec\x01\n" + + "\x14DatasourceFileHeader\x12F\n" + + "\n" + + "provenance\x18\x01 \x01(\v2&.saas.accounts.v1.DatasourceProvenanceR\n" + + "provenance\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12!\n" + + "\fcontent_type\x18\x03 \x01(\tR\vcontentType\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x12A\n" + + "\areaders\x18\x05 \x01(\v2'.saas.accounts.v1.DatasourceItemReadersR\areaders\"|\n" + + "\x19FetchDatasourceFilesFrame\x12@\n" + + "\x06header\x18\x01 \x01(\v2&.saas.accounts.v1.DatasourceFileHeaderH\x00R\x06header\x12\x14\n" + + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\a\n" + + "\x05frame\"\x80\x01\n" + "\x19ModulePublishEventRequest\x12 \n" + "\x06tenant\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06tenant\x12A\n" + "\benvelope\x18\x02 \x01(\v2\x1d.saas.events.v1.EventEnvelopeB\x06\xbaH\x03\xc8\x01\x01R\benvelope\"A\n" + @@ -3056,7 +4485,33 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\n" + "binding_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\tbindingId\x12F\n" + - "\x19parent_work_context_token\x18\x02 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken*i\n" + + "\x19parent_work_context_token\x18\x02 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken\"\xb9\x03\n" + + "/ModuleExchangeDelegatedOperationAudienceRequest\x12)\n" + + "\n" + + "binding_id\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\tbindingId\x12D\n" + + "\x19parent_work_context_token\x18\x02 \x01(\tB\t\xbaH\x06r\x04\x18\x80\x80\x02R\x16parentWorkContextToken\x12\x16\n" + + "\x06lookup\x18\x03 \x01(\bR\x06lookup\x120\n" + + "\rdelegation_id\x18\x04 \x01(\tB\v\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01R\fdelegationId:\xca\x01\xbaH\xc6\x01\x1a\xc3\x01\n" + + "3exchange_delegated_operation_audience.one_authority\x12Fexactly one of parent_work_context_token and delegation_id is required\x1aD(this.parent_work_context_token != '') != (this.delegation_id != '')*\xb8\x02\n" + + "\x14ModuleAuditFieldKind\x12'\n" + + "#MODULE_AUDIT_FIELD_KIND_UNSPECIFIED\x10\x00\x12\"\n" + + "\x1eMODULE_AUDIT_FIELD_KIND_STRING\x10\x01\x12 \n" + + "\x1cMODULE_AUDIT_FIELD_KIND_UUID\x10\x02\x12\x1f\n" + + "\x1bMODULE_AUDIT_FIELD_KIND_INT\x10\x03\x12\"\n" + + "\x1eMODULE_AUDIT_FIELD_KIND_NUMBER\x10\x04\x12 \n" + + "\x1cMODULE_AUDIT_FIELD_KIND_BOOL\x10\x05\x12 \n" + + "\x1cMODULE_AUDIT_FIELD_KIND_ENUM\x10\x06\x12(\n" + + "$MODULE_AUDIT_FIELD_KIND_STRING_ARRAY\x10\a*\xa1\x01\n" + + "\x1aModuleAuditEventVisibility\x12-\n" + + ")MODULE_AUDIT_EVENT_VISIBILITY_UNSPECIFIED\x10\x00\x12(\n" + + "$MODULE_AUDIT_EVENT_VISIBILITY_TENANT\x10\x01\x12*\n" + + "&MODULE_AUDIT_EVENT_VISIBILITY_EXTERNAL\x10\x02*\xdc\x01\n" + + "\x1aDatasourceItemReadersBasis\x12-\n" + + ")DATASOURCE_ITEM_READERS_BASIS_UNSPECIFIED\x10\x00\x12,\n" + + "(DATASOURCE_ITEM_READERS_BASIS_TRANSLATED\x10\x01\x12/\n" + + "+DATASOURCE_ITEM_READERS_BASIS_SOURCE_SCOPED\x10\x02\x120\n" + + ",DATASOURCE_ITEM_READERS_BASIS_UNTRANSLATABLE\x10\x03*i\n" + "\rEventDelivery\x12\x1e\n" + "\x1aEVENT_DELIVERY_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18EVENT_DELIVERY_UNORDERED\x10\x01\x12\x1a\n" + @@ -3067,9 +4522,12 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\x1cMETADATA_DISCLOSURE_WITHHELD\x10\x02*\\\n" + "\x0fSourceSyncStage\x12!\n" + "\x1dSOURCE_SYNC_STAGE_UNSPECIFIED\x10\x00\x12&\n" + - "\"SOURCE_SYNC_STAGE_CHANGES_ENQUEUED\x10\x012\x90\x19\n" + - "\x19ModuleCapabilitiesService\x12\x9c\x01\n" + - "\x1dExchangeDelegatedReadAudience\x12<.saas.accounts.v1.ModuleExchangeDelegatedReadAudienceRequest\x1a#.saas.accounts.v1.IssuedWorkContext\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x04X\x04`\x01\x12\xa7\x01\n" + + "\"SOURCE_SYNC_STAGE_CHANGES_ENQUEUED\x10\x012\xd1#\n" + + "\x19ModuleCapabilitiesService\x12\xc5\x01\n" + + "\x1dExchangeDelegatedReadAudience\x12<.saas.accounts.v1.ModuleExchangeDelegatedReadAudienceRequest\x1a#.saas.accounts.v1.IssuedWorkContext\"A\xc2\xf3\x18=\b\x03\x10\x010\x01:+\n" + + "'saas.module.delegated_audience_exchange\x10\x04@\x01H\aP\x04X\x04`\x01\x12\xcf\x01\n" + + "\"ExchangeDelegatedOperationAudience\x12A.saas.accounts.v1.ModuleExchangeDelegatedOperationAudienceRequest\x1a#.saas.accounts.v1.IssuedWorkContext\"A\xc2\xf3\x18=\b\x03\x10\x010\x01:+\n" + + "'saas.module.delegated_audience_exchange\x10\x04@\x01H\aP\x04X\x04`\x01\x12\xa7\x01\n" + "\x1cCheckWorkContextRecordAccess\x125.saas.accounts.v1.CheckWorkContextRecordAccessRequest\x1a6.saas.accounts.v1.CheckWorkContextRecordAccessResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\xaa\x01\n" + "\x1dListReadableSourceCollections\x126.saas.accounts.v1.ListReadableSourceCollectionsRequest\x1a7.saas.accounts.v1.ListReadableSourceCollectionsResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x80\x01\n" + "\vPlaceRecord\x12*.saas.accounts.v1.ModulePlaceRecordRequest\x1a+.saas.accounts.v1.ModulePlaceRecordResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12}\n" + @@ -3080,18 +4538,28 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\x06AckJob\x12%.saas.accounts.v1.ModuleAckJobRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12c\n" + "\aNackJob\x12&.saas.accounts.v1.ModuleNackJobRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12}\n" + "\n" + - "NotifyUser\x12).saas.accounts.v1.ModuleNotifyUserRequest\x1a*.saas.accounts.v1.ModuleNotifyUserResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x8c\x01\n" + + "NotifyUser\x12).saas.accounts.v1.ModuleNotifyUserRequest\x1a*.saas.accounts.v1.ModuleNotifyUserResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\xad\x01\n" + + "\x0fNotifyOrgAdmins\x12..saas.accounts.v1.ModuleNotifyOrgAdminsRequest\x1a/.saas.accounts.v1.ModuleNotifyOrgAdminsResponse\"9\xc2\xf3\x185\b\x03\x10\x010\x01:#\n" + + "\x1fsaas.module.org_admins_notified\x10\x02@\x01H\aP\x03X\x03`\x01\x12\x8c\x01\n" + "\x0fRequestApproval\x12..saas.accounts.v1.ModuleRequestApprovalRequest\x1a/.saas.accounts.v1.ModuleRequestApprovalResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12u\n" + "\vGetApproval\x12*.saas.accounts.v1.ModuleGetApprovalRequest\x1a .saas.accounts.v1.ModuleApproval\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12q\n" + "\x0eCancelApproval\x12-.saas.accounts.v1.ModuleCancelApprovalRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12q\n" + - "\x0eEmitAuditEvent\x12-.saas.accounts.v1.ModuleEmitAuditEventRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x8b\x01\n" + - "\x13FetchDatasourceBlob\x12,.saas.accounts.v1.FetchDatasourceBlobRequest\x1a*.saas.accounts.v1.FetchDatasourceBlobChunk\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x010\x01\x12\xb6\x01\n" + + "\x0eEmitAuditEvent\x12-.saas.accounts.v1.ModuleEmitAuditEventRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\xc3\x01\n" + + "\x16DeclareAuditEventTypes\x125.saas.accounts.v1.ModuleDeclareAuditEventTypesRequest\x1a6.saas.accounts.v1.ModuleDeclareAuditEventTypesResponse\":\xc2\xf3\x186\b\x03\x10\x010\x01:$\n" + + " saas.module.audit_types_declared\x10\x02@\x01H\aP\x03X\x03`\x01\x12\x9e\x01\n" + + "\x15ListSubjectVisibility\x124.saas.accounts.v1.ModuleListSubjectVisibilityRequest\x1a5.saas.accounts.v1.ModuleListSubjectVisibilityResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x8e\x01\n" + + "\x13FetchDatasourceBlob\x12,.saas.accounts.v1.FetchDatasourceBlobRequest\x1a*.saas.accounts.v1.FetchDatasourceBlobChunk\"\x1b\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x88\x02\x010\x01\x12\x8e\x01\n" + + "\x14FetchDatasourceFiles\x12-.saas.accounts.v1.FetchDatasourceFilesRequest\x1a+.saas.accounts.v1.FetchDatasourceFilesFrame\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x010\x01\x12\xb6\x01\n" + "\x16MintModuleRegistration\x12/.saas.accounts.v1.ModuleMintRegistrationRequest\x1a0.saas.accounts.v1.ModuleMintRegistrationResponse\"9\xc2\xf3\x185\b\x03\x10\x010\x01:#\n" + "\x1fsaas.module.registration_minted\x10\x02@\x01H\aP\x04X\x04`\x01\x12\xbe\x01\n" + "\x18MintSolutionRegistration\x121.saas.accounts.v1.SolutionMintRegistrationRequest\x1a2.saas.accounts.v1.SolutionMintRegistrationResponse\";\xc2\xf3\x187\b\x03\x10\x010\x01:%\n" + "!saas.solution.registration_minted\x10\x02@\x01H\aP\x04X\x04`\x01\x12\xb3\x01\n" + "\x15MintModuleWorkContext\x12..saas.accounts.v1.ModuleMintWorkContextRequest\x1a/.saas.accounts.v1.ModuleMintWorkContextResponse\"9\xc2\xf3\x185\b\x03\x10\x010\x01:#\n" + - "\x1fsaas.module.work_context_minted\x10\x02@\x01H\aP\x04X\x04`\x01\x12\x83\x01\n" + + "\x1fsaas.module.work_context_minted\x10\x02@\x01H\aP\x04X\x04`\x01\x12\xc7\x01\n" + + "\x1aMintModuleOperationContext\x123.saas.accounts.v1.ModuleMintOperationContextRequest\x1a4.saas.accounts.v1.ModuleMintOperationContextResponse\">\xc2\xf3\x18:\b\x03\x10\x010\x01:(\n" + + "$saas.module.operation_context_minted\x10\x02@\x01H\aP\x04X\x04`\x01\x12\xce\x01\n" + + "\x1aMintSourceOperationContext\x129.saas.accounts.v1.ModuleMintSourceOperationContextRequest\x1a:.saas.accounts.v1.ModuleMintSourceOperationContextResponse\"9\xc2\xf3\x185\b\x03\x10\x010\x01:#\n" + + "\x1fsaas.datasource.delegation.used\x10\x02@\x01H\aP\x04X\x04`\x01\x12\x83\x01\n" + "\fPublishEvent\x12+.saas.accounts.v1.ModulePublishEventRequest\x1a,.saas.accounts.v1.ModulePublishEventResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12z\n" + "\tSubscribe\x12(.saas.accounts.v1.ModuleSubscribeRequest\x1a).saas.accounts.v1.ModuleSubscribeResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12k\n" + "\vUnsubscribe\x12*.saas.accounts.v1.ModuleUnsubscribeRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x92\x01\n" + @@ -3110,162 +4578,210 @@ func file_saas_accounts_v1_module_capabilities_proto_rawDescGZIP() []byte { return file_saas_accounts_v1_module_capabilities_proto_rawDescData } -var file_saas_accounts_v1_module_capabilities_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_saas_accounts_v1_module_capabilities_proto_msgTypes = make([]protoimpl.MessageInfo, 40) +var file_saas_accounts_v1_module_capabilities_proto_enumTypes = make([]protoimpl.EnumInfo, 6) +var file_saas_accounts_v1_module_capabilities_proto_msgTypes = make([]protoimpl.MessageInfo, 56) var file_saas_accounts_v1_module_capabilities_proto_goTypes = []any{ - (EventDelivery)(0), // 0: saas.accounts.v1.EventDelivery - (MetadataDisclosure)(0), // 1: saas.accounts.v1.MetadataDisclosure - (SourceSyncStage)(0), // 2: saas.accounts.v1.SourceSyncStage - (*ModuleEnqueueJobRequest)(nil), // 3: saas.accounts.v1.ModuleEnqueueJobRequest - (*ModuleEnqueueJobResponse)(nil), // 4: saas.accounts.v1.ModuleEnqueueJobResponse - (*ModuleClaimJobsRequest)(nil), // 5: saas.accounts.v1.ModuleClaimJobsRequest - (*ModuleClaimJobsResponse)(nil), // 6: saas.accounts.v1.ModuleClaimJobsResponse - (*ModuleHeartbeatJobRequest)(nil), // 7: saas.accounts.v1.ModuleHeartbeatJobRequest - (*ModuleHeartbeatJobResponse)(nil), // 8: saas.accounts.v1.ModuleHeartbeatJobResponse - (*ModuleAckJobRequest)(nil), // 9: saas.accounts.v1.ModuleAckJobRequest - (*ModuleNackJobRequest)(nil), // 10: saas.accounts.v1.ModuleNackJobRequest - (*ModuleNotifyUserRequest)(nil), // 11: saas.accounts.v1.ModuleNotifyUserRequest - (*ModuleNotifyUserResponse)(nil), // 12: saas.accounts.v1.ModuleNotifyUserResponse - (*ModuleApprovalPolicy)(nil), // 13: saas.accounts.v1.ModuleApprovalPolicy - (*ModuleResumeRef)(nil), // 14: saas.accounts.v1.ModuleResumeRef - (*ModuleRequestApprovalRequest)(nil), // 15: saas.accounts.v1.ModuleRequestApprovalRequest - (*ModuleRequestApprovalResponse)(nil), // 16: saas.accounts.v1.ModuleRequestApprovalResponse - (*ModuleGetApprovalRequest)(nil), // 17: saas.accounts.v1.ModuleGetApprovalRequest - (*ModuleApproval)(nil), // 18: saas.accounts.v1.ModuleApproval - (*ModuleCancelApprovalRequest)(nil), // 19: saas.accounts.v1.ModuleCancelApprovalRequest - (*ModuleEmitAuditEventRequest)(nil), // 20: saas.accounts.v1.ModuleEmitAuditEventRequest - (*FetchDatasourceBlobRequest)(nil), // 21: saas.accounts.v1.FetchDatasourceBlobRequest - (*FetchDatasourceBlobChunk)(nil), // 22: saas.accounts.v1.FetchDatasourceBlobChunk - (*ModulePublishEventRequest)(nil), // 23: saas.accounts.v1.ModulePublishEventRequest - (*ModulePublishEventResponse)(nil), // 24: saas.accounts.v1.ModulePublishEventResponse - (*ModuleSubscribeRequest)(nil), // 25: saas.accounts.v1.ModuleSubscribeRequest - (*ModuleSubscription)(nil), // 26: saas.accounts.v1.ModuleSubscription - (*ModuleSubscribeResponse)(nil), // 27: saas.accounts.v1.ModuleSubscribeResponse - (*ModuleUnsubscribeRequest)(nil), // 28: saas.accounts.v1.ModuleUnsubscribeRequest - (*ModuleListSubscriptionsRequest)(nil), // 29: saas.accounts.v1.ModuleListSubscriptionsRequest - (*ModuleListSubscriptionsResponse)(nil), // 30: saas.accounts.v1.ModuleListSubscriptionsResponse - (*ModuleReplayEventsRequest)(nil), // 31: saas.accounts.v1.ModuleReplayEventsRequest - (*ModuleReplayEventsResponse)(nil), // 32: saas.accounts.v1.ModuleReplayEventsResponse - (*ReadableSourceCollection)(nil), // 33: saas.accounts.v1.ReadableSourceCollection - (*ReadableCollectionGrant)(nil), // 34: saas.accounts.v1.ReadableCollectionGrant - (*CollectionSyncProvenance)(nil), // 35: saas.accounts.v1.CollectionSyncProvenance - (*ListReadableSourceCollectionsRequest)(nil), // 36: saas.accounts.v1.ListReadableSourceCollectionsRequest - (*ListReadableSourceCollectionsResponse)(nil), // 37: saas.accounts.v1.ListReadableSourceCollectionsResponse - (*ModulePlaceRecordRequest)(nil), // 38: saas.accounts.v1.ModulePlaceRecordRequest - (*ModulePlaceRecordResponse)(nil), // 39: saas.accounts.v1.ModulePlaceRecordResponse - (*CheckWorkContextRecordAccessRequest)(nil), // 40: saas.accounts.v1.CheckWorkContextRecordAccessRequest - (*CheckWorkContextRecordAccessResponse)(nil), // 41: saas.accounts.v1.CheckWorkContextRecordAccessResponse - (*ModuleExchangeDelegatedReadAudienceRequest)(nil), // 42: saas.accounts.v1.ModuleExchangeDelegatedReadAudienceRequest - (*v1.NewJob)(nil), // 43: saas.jobs.v1.NewJob - (v1.JobEnqueueDisposition)(0), // 44: saas.jobs.v1.JobEnqueueDisposition - (*durationpb.Duration)(nil), // 45: google.protobuf.Duration - (*v1.JobEnvelope)(nil), // 46: saas.jobs.v1.JobEnvelope - (*v1.JobLeaseReference)(nil), // 47: saas.jobs.v1.JobLeaseReference - (*v1.JobLease)(nil), // 48: saas.jobs.v1.JobLease - (*v1.JobFailure)(nil), // 49: saas.jobs.v1.JobFailure - (*timestamppb.Timestamp)(nil), // 50: google.protobuf.Timestamp - (*structpb.Struct)(nil), // 51: google.protobuf.Struct - (*v11.EventEnvelope)(nil), // 52: saas.events.v1.EventEnvelope - (*ModuleMintRegistrationRequest)(nil), // 53: saas.accounts.v1.ModuleMintRegistrationRequest - (*SolutionMintRegistrationRequest)(nil), // 54: saas.accounts.v1.SolutionMintRegistrationRequest - (*ModuleMintWorkContextRequest)(nil), // 55: saas.accounts.v1.ModuleMintWorkContextRequest - (*v12.IssuedWorkContext)(nil), // 56: saas.accounts.v1.IssuedWorkContext - (*emptypb.Empty)(nil), // 57: google.protobuf.Empty - (*ModuleMintRegistrationResponse)(nil), // 58: saas.accounts.v1.ModuleMintRegistrationResponse - (*SolutionMintRegistrationResponse)(nil), // 59: saas.accounts.v1.SolutionMintRegistrationResponse - (*ModuleMintWorkContextResponse)(nil), // 60: saas.accounts.v1.ModuleMintWorkContextResponse + (ModuleAuditFieldKind)(0), // 0: saas.accounts.v1.ModuleAuditFieldKind + (ModuleAuditEventVisibility)(0), // 1: saas.accounts.v1.ModuleAuditEventVisibility + (DatasourceItemReadersBasis)(0), // 2: saas.accounts.v1.DatasourceItemReadersBasis + (EventDelivery)(0), // 3: saas.accounts.v1.EventDelivery + (MetadataDisclosure)(0), // 4: saas.accounts.v1.MetadataDisclosure + (SourceSyncStage)(0), // 5: saas.accounts.v1.SourceSyncStage + (*ModuleEnqueueJobRequest)(nil), // 6: saas.accounts.v1.ModuleEnqueueJobRequest + (*ModuleEnqueueJobResponse)(nil), // 7: saas.accounts.v1.ModuleEnqueueJobResponse + (*ModuleClaimJobsRequest)(nil), // 8: saas.accounts.v1.ModuleClaimJobsRequest + (*ModuleClaimJobsResponse)(nil), // 9: saas.accounts.v1.ModuleClaimJobsResponse + (*ModuleHeartbeatJobRequest)(nil), // 10: saas.accounts.v1.ModuleHeartbeatJobRequest + (*ModuleHeartbeatJobResponse)(nil), // 11: saas.accounts.v1.ModuleHeartbeatJobResponse + (*ModuleAckJobRequest)(nil), // 12: saas.accounts.v1.ModuleAckJobRequest + (*ModuleNackJobRequest)(nil), // 13: saas.accounts.v1.ModuleNackJobRequest + (*ModuleNotifyUserRequest)(nil), // 14: saas.accounts.v1.ModuleNotifyUserRequest + (*ModuleNotifyUserResponse)(nil), // 15: saas.accounts.v1.ModuleNotifyUserResponse + (*ModuleNotifyOrgAdminsRequest)(nil), // 16: saas.accounts.v1.ModuleNotifyOrgAdminsRequest + (*ModuleNotifyOrgAdminsResponse)(nil), // 17: saas.accounts.v1.ModuleNotifyOrgAdminsResponse + (*ModuleApprovalPolicy)(nil), // 18: saas.accounts.v1.ModuleApprovalPolicy + (*ModuleResumeRef)(nil), // 19: saas.accounts.v1.ModuleResumeRef + (*ModuleRequestApprovalRequest)(nil), // 20: saas.accounts.v1.ModuleRequestApprovalRequest + (*ModuleRequestApprovalResponse)(nil), // 21: saas.accounts.v1.ModuleRequestApprovalResponse + (*ModuleGetApprovalRequest)(nil), // 22: saas.accounts.v1.ModuleGetApprovalRequest + (*ModuleApproval)(nil), // 23: saas.accounts.v1.ModuleApproval + (*ModuleCancelApprovalRequest)(nil), // 24: saas.accounts.v1.ModuleCancelApprovalRequest + (*ModuleEmitAuditEventRequest)(nil), // 25: saas.accounts.v1.ModuleEmitAuditEventRequest + (*ModuleAuditFieldDeclaration)(nil), // 26: saas.accounts.v1.ModuleAuditFieldDeclaration + (*ModuleAuditEventTypeDeclaration)(nil), // 27: saas.accounts.v1.ModuleAuditEventTypeDeclaration + (*ModuleDeclareAuditEventTypesRequest)(nil), // 28: saas.accounts.v1.ModuleDeclareAuditEventTypesRequest + (*ModuleDeclareAuditEventTypesResponse)(nil), // 29: saas.accounts.v1.ModuleDeclareAuditEventTypesResponse + (*SubjectVisibilityGrant)(nil), // 30: saas.accounts.v1.SubjectVisibilityGrant + (*ModuleListSubjectVisibilityRequest)(nil), // 31: saas.accounts.v1.ModuleListSubjectVisibilityRequest + (*ModuleListSubjectVisibilityResponse)(nil), // 32: saas.accounts.v1.ModuleListSubjectVisibilityResponse + (*FetchDatasourceBlobRequest)(nil), // 33: saas.accounts.v1.FetchDatasourceBlobRequest + (*FetchDatasourceBlobChunk)(nil), // 34: saas.accounts.v1.FetchDatasourceBlobChunk + (*DatasourceFileRef)(nil), // 35: saas.accounts.v1.DatasourceFileRef + (*FetchDatasourceFilesRequest)(nil), // 36: saas.accounts.v1.FetchDatasourceFilesRequest + (*DatasourceProvenance)(nil), // 37: saas.accounts.v1.DatasourceProvenance + (*DatasourceItemReaders)(nil), // 38: saas.accounts.v1.DatasourceItemReaders + (*DatasourceFileHeader)(nil), // 39: saas.accounts.v1.DatasourceFileHeader + (*FetchDatasourceFilesFrame)(nil), // 40: saas.accounts.v1.FetchDatasourceFilesFrame + (*ModulePublishEventRequest)(nil), // 41: saas.accounts.v1.ModulePublishEventRequest + (*ModulePublishEventResponse)(nil), // 42: saas.accounts.v1.ModulePublishEventResponse + (*ModuleSubscribeRequest)(nil), // 43: saas.accounts.v1.ModuleSubscribeRequest + (*ModuleSubscription)(nil), // 44: saas.accounts.v1.ModuleSubscription + (*ModuleSubscribeResponse)(nil), // 45: saas.accounts.v1.ModuleSubscribeResponse + (*ModuleUnsubscribeRequest)(nil), // 46: saas.accounts.v1.ModuleUnsubscribeRequest + (*ModuleListSubscriptionsRequest)(nil), // 47: saas.accounts.v1.ModuleListSubscriptionsRequest + (*ModuleListSubscriptionsResponse)(nil), // 48: saas.accounts.v1.ModuleListSubscriptionsResponse + (*ModuleReplayEventsRequest)(nil), // 49: saas.accounts.v1.ModuleReplayEventsRequest + (*ModuleReplayEventsResponse)(nil), // 50: saas.accounts.v1.ModuleReplayEventsResponse + (*ReadableSourceCollection)(nil), // 51: saas.accounts.v1.ReadableSourceCollection + (*ReadableCollectionGrant)(nil), // 52: saas.accounts.v1.ReadableCollectionGrant + (*CollectionSyncProvenance)(nil), // 53: saas.accounts.v1.CollectionSyncProvenance + (*ListReadableSourceCollectionsRequest)(nil), // 54: saas.accounts.v1.ListReadableSourceCollectionsRequest + (*ListReadableSourceCollectionsResponse)(nil), // 55: saas.accounts.v1.ListReadableSourceCollectionsResponse + (*ModulePlaceRecordRequest)(nil), // 56: saas.accounts.v1.ModulePlaceRecordRequest + (*ModulePlaceRecordResponse)(nil), // 57: saas.accounts.v1.ModulePlaceRecordResponse + (*CheckWorkContextRecordAccessRequest)(nil), // 58: saas.accounts.v1.CheckWorkContextRecordAccessRequest + (*CheckWorkContextRecordAccessResponse)(nil), // 59: saas.accounts.v1.CheckWorkContextRecordAccessResponse + (*ModuleExchangeDelegatedReadAudienceRequest)(nil), // 60: saas.accounts.v1.ModuleExchangeDelegatedReadAudienceRequest + (*ModuleExchangeDelegatedOperationAudienceRequest)(nil), // 61: saas.accounts.v1.ModuleExchangeDelegatedOperationAudienceRequest + (*v1.NewJob)(nil), // 62: saas.jobs.v1.NewJob + (v1.JobEnqueueDisposition)(0), // 63: saas.jobs.v1.JobEnqueueDisposition + (*durationpb.Duration)(nil), // 64: google.protobuf.Duration + (*v1.JobEnvelope)(nil), // 65: saas.jobs.v1.JobEnvelope + (*v1.JobLeaseReference)(nil), // 66: saas.jobs.v1.JobLeaseReference + (*v1.JobLease)(nil), // 67: saas.jobs.v1.JobLease + (*v1.JobFailure)(nil), // 68: saas.jobs.v1.JobFailure + (*timestamppb.Timestamp)(nil), // 69: google.protobuf.Timestamp + (*structpb.Struct)(nil), // 70: google.protobuf.Struct + (*v11.EventEnvelope)(nil), // 71: saas.events.v1.EventEnvelope + (*ModuleMintRegistrationRequest)(nil), // 72: saas.accounts.v1.ModuleMintRegistrationRequest + (*SolutionMintRegistrationRequest)(nil), // 73: saas.accounts.v1.SolutionMintRegistrationRequest + (*ModuleMintWorkContextRequest)(nil), // 74: saas.accounts.v1.ModuleMintWorkContextRequest + (*ModuleMintOperationContextRequest)(nil), // 75: saas.accounts.v1.ModuleMintOperationContextRequest + (*ModuleMintSourceOperationContextRequest)(nil), // 76: saas.accounts.v1.ModuleMintSourceOperationContextRequest + (*v12.IssuedWorkContext)(nil), // 77: saas.accounts.v1.IssuedWorkContext + (*emptypb.Empty)(nil), // 78: google.protobuf.Empty + (*ModuleMintRegistrationResponse)(nil), // 79: saas.accounts.v1.ModuleMintRegistrationResponse + (*SolutionMintRegistrationResponse)(nil), // 80: saas.accounts.v1.SolutionMintRegistrationResponse + (*ModuleMintWorkContextResponse)(nil), // 81: saas.accounts.v1.ModuleMintWorkContextResponse + (*ModuleMintOperationContextResponse)(nil), // 82: saas.accounts.v1.ModuleMintOperationContextResponse + (*ModuleMintSourceOperationContextResponse)(nil), // 83: saas.accounts.v1.ModuleMintSourceOperationContextResponse } var file_saas_accounts_v1_module_capabilities_proto_depIdxs = []int32{ - 43, // 0: saas.accounts.v1.ModuleEnqueueJobRequest.job:type_name -> saas.jobs.v1.NewJob - 44, // 1: saas.accounts.v1.ModuleEnqueueJobResponse.disposition:type_name -> saas.jobs.v1.JobEnqueueDisposition - 45, // 2: saas.accounts.v1.ModuleClaimJobsRequest.lease_duration:type_name -> google.protobuf.Duration - 46, // 3: saas.accounts.v1.ModuleClaimJobsResponse.jobs:type_name -> saas.jobs.v1.JobEnvelope - 47, // 4: saas.accounts.v1.ModuleHeartbeatJobRequest.lease:type_name -> saas.jobs.v1.JobLeaseReference - 45, // 5: saas.accounts.v1.ModuleHeartbeatJobRequest.extension:type_name -> google.protobuf.Duration - 48, // 6: saas.accounts.v1.ModuleHeartbeatJobResponse.lease:type_name -> saas.jobs.v1.JobLease - 47, // 7: saas.accounts.v1.ModuleAckJobRequest.lease:type_name -> saas.jobs.v1.JobLeaseReference - 47, // 8: saas.accounts.v1.ModuleNackJobRequest.lease:type_name -> saas.jobs.v1.JobLeaseReference - 49, // 9: saas.accounts.v1.ModuleNackJobRequest.failure:type_name -> saas.jobs.v1.JobFailure - 50, // 10: saas.accounts.v1.ModuleNackJobRequest.retry_at:type_name -> google.protobuf.Timestamp - 51, // 11: saas.accounts.v1.ModuleResumeRef.payload:type_name -> google.protobuf.Struct - 51, // 12: saas.accounts.v1.ModuleRequestApprovalRequest.subject:type_name -> google.protobuf.Struct - 13, // 13: saas.accounts.v1.ModuleRequestApprovalRequest.policy:type_name -> saas.accounts.v1.ModuleApprovalPolicy - 14, // 14: saas.accounts.v1.ModuleRequestApprovalRequest.resume_ref:type_name -> saas.accounts.v1.ModuleResumeRef - 50, // 15: saas.accounts.v1.ModuleRequestApprovalRequest.expires_at:type_name -> google.protobuf.Timestamp - 50, // 16: saas.accounts.v1.ModuleRequestApprovalRequest.escalate_at:type_name -> google.protobuf.Timestamp - 51, // 17: saas.accounts.v1.ModuleApproval.subject:type_name -> google.protobuf.Struct - 14, // 18: saas.accounts.v1.ModuleApproval.resume_ref:type_name -> saas.accounts.v1.ModuleResumeRef - 50, // 19: saas.accounts.v1.ModuleApproval.expires_at:type_name -> google.protobuf.Timestamp - 50, // 20: saas.accounts.v1.ModuleApproval.escalate_at:type_name -> google.protobuf.Timestamp - 50, // 21: saas.accounts.v1.ModuleApproval.created_at:type_name -> google.protobuf.Timestamp - 51, // 22: saas.accounts.v1.ModuleEmitAuditEventRequest.fields:type_name -> google.protobuf.Struct - 52, // 23: saas.accounts.v1.ModulePublishEventRequest.envelope:type_name -> saas.events.v1.EventEnvelope - 0, // 24: saas.accounts.v1.ModuleSubscribeRequest.delivery:type_name -> saas.accounts.v1.EventDelivery - 0, // 25: saas.accounts.v1.ModuleSubscription.delivery:type_name -> saas.accounts.v1.EventDelivery - 50, // 26: saas.accounts.v1.ModuleSubscription.created_at:type_name -> google.protobuf.Timestamp - 26, // 27: saas.accounts.v1.ModuleSubscribeResponse.subscription:type_name -> saas.accounts.v1.ModuleSubscription - 26, // 28: saas.accounts.v1.ModuleListSubscriptionsResponse.subscriptions:type_name -> saas.accounts.v1.ModuleSubscription - 50, // 29: saas.accounts.v1.ModuleReplayEventsRequest.since:type_name -> google.protobuf.Timestamp - 1, // 30: saas.accounts.v1.ReadableSourceCollection.grant_disclosure:type_name -> saas.accounts.v1.MetadataDisclosure - 34, // 31: saas.accounts.v1.ReadableSourceCollection.read_grants:type_name -> saas.accounts.v1.ReadableCollectionGrant - 35, // 32: saas.accounts.v1.ReadableSourceCollection.sync:type_name -> saas.accounts.v1.CollectionSyncProvenance - 50, // 33: saas.accounts.v1.ReadableCollectionGrant.expires_at:type_name -> google.protobuf.Timestamp - 2, // 34: saas.accounts.v1.CollectionSyncProvenance.stage:type_name -> saas.accounts.v1.SourceSyncStage - 50, // 35: saas.accounts.v1.CollectionSyncProvenance.at:type_name -> google.protobuf.Timestamp - 1, // 36: saas.accounts.v1.CollectionSyncProvenance.requester_disclosure:type_name -> saas.accounts.v1.MetadataDisclosure - 50, // 37: saas.accounts.v1.CollectionSyncProvenance.requested_at:type_name -> google.protobuf.Timestamp - 33, // 38: saas.accounts.v1.ListReadableSourceCollectionsResponse.collections:type_name -> saas.accounts.v1.ReadableSourceCollection - 42, // 39: saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedReadAudience:input_type -> saas.accounts.v1.ModuleExchangeDelegatedReadAudienceRequest - 40, // 40: saas.accounts.v1.ModuleCapabilitiesService.CheckWorkContextRecordAccess:input_type -> saas.accounts.v1.CheckWorkContextRecordAccessRequest - 36, // 41: saas.accounts.v1.ModuleCapabilitiesService.ListReadableSourceCollections:input_type -> saas.accounts.v1.ListReadableSourceCollectionsRequest - 38, // 42: saas.accounts.v1.ModuleCapabilitiesService.PlaceRecord:input_type -> saas.accounts.v1.ModulePlaceRecordRequest - 3, // 43: saas.accounts.v1.ModuleCapabilitiesService.EnqueueJob:input_type -> saas.accounts.v1.ModuleEnqueueJobRequest - 5, // 44: saas.accounts.v1.ModuleCapabilitiesService.ClaimJobs:input_type -> saas.accounts.v1.ModuleClaimJobsRequest - 7, // 45: saas.accounts.v1.ModuleCapabilitiesService.HeartbeatJob:input_type -> saas.accounts.v1.ModuleHeartbeatJobRequest - 9, // 46: saas.accounts.v1.ModuleCapabilitiesService.AckJob:input_type -> saas.accounts.v1.ModuleAckJobRequest - 10, // 47: saas.accounts.v1.ModuleCapabilitiesService.NackJob:input_type -> saas.accounts.v1.ModuleNackJobRequest - 11, // 48: saas.accounts.v1.ModuleCapabilitiesService.NotifyUser:input_type -> saas.accounts.v1.ModuleNotifyUserRequest - 15, // 49: saas.accounts.v1.ModuleCapabilitiesService.RequestApproval:input_type -> saas.accounts.v1.ModuleRequestApprovalRequest - 17, // 50: saas.accounts.v1.ModuleCapabilitiesService.GetApproval:input_type -> saas.accounts.v1.ModuleGetApprovalRequest - 19, // 51: saas.accounts.v1.ModuleCapabilitiesService.CancelApproval:input_type -> saas.accounts.v1.ModuleCancelApprovalRequest - 20, // 52: saas.accounts.v1.ModuleCapabilitiesService.EmitAuditEvent:input_type -> saas.accounts.v1.ModuleEmitAuditEventRequest - 21, // 53: saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceBlob:input_type -> saas.accounts.v1.FetchDatasourceBlobRequest - 53, // 54: saas.accounts.v1.ModuleCapabilitiesService.MintModuleRegistration:input_type -> saas.accounts.v1.ModuleMintRegistrationRequest - 54, // 55: saas.accounts.v1.ModuleCapabilitiesService.MintSolutionRegistration:input_type -> saas.accounts.v1.SolutionMintRegistrationRequest - 55, // 56: saas.accounts.v1.ModuleCapabilitiesService.MintModuleWorkContext:input_type -> saas.accounts.v1.ModuleMintWorkContextRequest - 23, // 57: saas.accounts.v1.ModuleCapabilitiesService.PublishEvent:input_type -> saas.accounts.v1.ModulePublishEventRequest - 25, // 58: saas.accounts.v1.ModuleCapabilitiesService.Subscribe:input_type -> saas.accounts.v1.ModuleSubscribeRequest - 28, // 59: saas.accounts.v1.ModuleCapabilitiesService.Unsubscribe:input_type -> saas.accounts.v1.ModuleUnsubscribeRequest - 29, // 60: saas.accounts.v1.ModuleCapabilitiesService.ListSubscriptions:input_type -> saas.accounts.v1.ModuleListSubscriptionsRequest - 31, // 61: saas.accounts.v1.ModuleCapabilitiesService.ReplayEvents:input_type -> saas.accounts.v1.ModuleReplayEventsRequest - 56, // 62: saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedReadAudience:output_type -> saas.accounts.v1.IssuedWorkContext - 41, // 63: saas.accounts.v1.ModuleCapabilitiesService.CheckWorkContextRecordAccess:output_type -> saas.accounts.v1.CheckWorkContextRecordAccessResponse - 37, // 64: saas.accounts.v1.ModuleCapabilitiesService.ListReadableSourceCollections:output_type -> saas.accounts.v1.ListReadableSourceCollectionsResponse - 39, // 65: saas.accounts.v1.ModuleCapabilitiesService.PlaceRecord:output_type -> saas.accounts.v1.ModulePlaceRecordResponse - 4, // 66: saas.accounts.v1.ModuleCapabilitiesService.EnqueueJob:output_type -> saas.accounts.v1.ModuleEnqueueJobResponse - 6, // 67: saas.accounts.v1.ModuleCapabilitiesService.ClaimJobs:output_type -> saas.accounts.v1.ModuleClaimJobsResponse - 8, // 68: saas.accounts.v1.ModuleCapabilitiesService.HeartbeatJob:output_type -> saas.accounts.v1.ModuleHeartbeatJobResponse - 57, // 69: saas.accounts.v1.ModuleCapabilitiesService.AckJob:output_type -> google.protobuf.Empty - 57, // 70: saas.accounts.v1.ModuleCapabilitiesService.NackJob:output_type -> google.protobuf.Empty - 12, // 71: saas.accounts.v1.ModuleCapabilitiesService.NotifyUser:output_type -> saas.accounts.v1.ModuleNotifyUserResponse - 16, // 72: saas.accounts.v1.ModuleCapabilitiesService.RequestApproval:output_type -> saas.accounts.v1.ModuleRequestApprovalResponse - 18, // 73: saas.accounts.v1.ModuleCapabilitiesService.GetApproval:output_type -> saas.accounts.v1.ModuleApproval - 57, // 74: saas.accounts.v1.ModuleCapabilitiesService.CancelApproval:output_type -> google.protobuf.Empty - 57, // 75: saas.accounts.v1.ModuleCapabilitiesService.EmitAuditEvent:output_type -> google.protobuf.Empty - 22, // 76: saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceBlob:output_type -> saas.accounts.v1.FetchDatasourceBlobChunk - 58, // 77: saas.accounts.v1.ModuleCapabilitiesService.MintModuleRegistration:output_type -> saas.accounts.v1.ModuleMintRegistrationResponse - 59, // 78: saas.accounts.v1.ModuleCapabilitiesService.MintSolutionRegistration:output_type -> saas.accounts.v1.SolutionMintRegistrationResponse - 60, // 79: saas.accounts.v1.ModuleCapabilitiesService.MintModuleWorkContext:output_type -> saas.accounts.v1.ModuleMintWorkContextResponse - 24, // 80: saas.accounts.v1.ModuleCapabilitiesService.PublishEvent:output_type -> saas.accounts.v1.ModulePublishEventResponse - 27, // 81: saas.accounts.v1.ModuleCapabilitiesService.Subscribe:output_type -> saas.accounts.v1.ModuleSubscribeResponse - 57, // 82: saas.accounts.v1.ModuleCapabilitiesService.Unsubscribe:output_type -> google.protobuf.Empty - 30, // 83: saas.accounts.v1.ModuleCapabilitiesService.ListSubscriptions:output_type -> saas.accounts.v1.ModuleListSubscriptionsResponse - 32, // 84: saas.accounts.v1.ModuleCapabilitiesService.ReplayEvents:output_type -> saas.accounts.v1.ModuleReplayEventsResponse - 62, // [62:85] is the sub-list for method output_type - 39, // [39:62] is the sub-list for method input_type - 39, // [39:39] is the sub-list for extension type_name - 39, // [39:39] is the sub-list for extension extendee - 0, // [0:39] is the sub-list for field type_name + 62, // 0: saas.accounts.v1.ModuleEnqueueJobRequest.job:type_name -> saas.jobs.v1.NewJob + 63, // 1: saas.accounts.v1.ModuleEnqueueJobResponse.disposition:type_name -> saas.jobs.v1.JobEnqueueDisposition + 64, // 2: saas.accounts.v1.ModuleClaimJobsRequest.lease_duration:type_name -> google.protobuf.Duration + 65, // 3: saas.accounts.v1.ModuleClaimJobsResponse.jobs:type_name -> saas.jobs.v1.JobEnvelope + 66, // 4: saas.accounts.v1.ModuleHeartbeatJobRequest.lease:type_name -> saas.jobs.v1.JobLeaseReference + 64, // 5: saas.accounts.v1.ModuleHeartbeatJobRequest.extension:type_name -> google.protobuf.Duration + 67, // 6: saas.accounts.v1.ModuleHeartbeatJobResponse.lease:type_name -> saas.jobs.v1.JobLease + 66, // 7: saas.accounts.v1.ModuleAckJobRequest.lease:type_name -> saas.jobs.v1.JobLeaseReference + 66, // 8: saas.accounts.v1.ModuleNackJobRequest.lease:type_name -> saas.jobs.v1.JobLeaseReference + 68, // 9: saas.accounts.v1.ModuleNackJobRequest.failure:type_name -> saas.jobs.v1.JobFailure + 69, // 10: saas.accounts.v1.ModuleNackJobRequest.retry_at:type_name -> google.protobuf.Timestamp + 70, // 11: saas.accounts.v1.ModuleResumeRef.payload:type_name -> google.protobuf.Struct + 70, // 12: saas.accounts.v1.ModuleRequestApprovalRequest.subject:type_name -> google.protobuf.Struct + 18, // 13: saas.accounts.v1.ModuleRequestApprovalRequest.policy:type_name -> saas.accounts.v1.ModuleApprovalPolicy + 19, // 14: saas.accounts.v1.ModuleRequestApprovalRequest.resume_ref:type_name -> saas.accounts.v1.ModuleResumeRef + 69, // 15: saas.accounts.v1.ModuleRequestApprovalRequest.expires_at:type_name -> google.protobuf.Timestamp + 69, // 16: saas.accounts.v1.ModuleRequestApprovalRequest.escalate_at:type_name -> google.protobuf.Timestamp + 70, // 17: saas.accounts.v1.ModuleApproval.subject:type_name -> google.protobuf.Struct + 19, // 18: saas.accounts.v1.ModuleApproval.resume_ref:type_name -> saas.accounts.v1.ModuleResumeRef + 69, // 19: saas.accounts.v1.ModuleApproval.expires_at:type_name -> google.protobuf.Timestamp + 69, // 20: saas.accounts.v1.ModuleApproval.escalate_at:type_name -> google.protobuf.Timestamp + 69, // 21: saas.accounts.v1.ModuleApproval.created_at:type_name -> google.protobuf.Timestamp + 70, // 22: saas.accounts.v1.ModuleEmitAuditEventRequest.fields:type_name -> google.protobuf.Struct + 0, // 23: saas.accounts.v1.ModuleAuditFieldDeclaration.kind:type_name -> saas.accounts.v1.ModuleAuditFieldKind + 26, // 24: saas.accounts.v1.ModuleAuditEventTypeDeclaration.fields:type_name -> saas.accounts.v1.ModuleAuditFieldDeclaration + 1, // 25: saas.accounts.v1.ModuleAuditEventTypeDeclaration.visibility:type_name -> saas.accounts.v1.ModuleAuditEventVisibility + 27, // 26: saas.accounts.v1.ModuleDeclareAuditEventTypesRequest.types:type_name -> saas.accounts.v1.ModuleAuditEventTypeDeclaration + 69, // 27: saas.accounts.v1.SubjectVisibilityGrant.expires_at:type_name -> google.protobuf.Timestamp + 30, // 28: saas.accounts.v1.ModuleListSubjectVisibilityResponse.grants:type_name -> saas.accounts.v1.SubjectVisibilityGrant + 35, // 29: saas.accounts.v1.FetchDatasourceFilesRequest.files:type_name -> saas.accounts.v1.DatasourceFileRef + 2, // 30: saas.accounts.v1.DatasourceItemReaders.basis:type_name -> saas.accounts.v1.DatasourceItemReadersBasis + 37, // 31: saas.accounts.v1.DatasourceFileHeader.provenance:type_name -> saas.accounts.v1.DatasourceProvenance + 38, // 32: saas.accounts.v1.DatasourceFileHeader.readers:type_name -> saas.accounts.v1.DatasourceItemReaders + 39, // 33: saas.accounts.v1.FetchDatasourceFilesFrame.header:type_name -> saas.accounts.v1.DatasourceFileHeader + 71, // 34: saas.accounts.v1.ModulePublishEventRequest.envelope:type_name -> saas.events.v1.EventEnvelope + 3, // 35: saas.accounts.v1.ModuleSubscribeRequest.delivery:type_name -> saas.accounts.v1.EventDelivery + 3, // 36: saas.accounts.v1.ModuleSubscription.delivery:type_name -> saas.accounts.v1.EventDelivery + 69, // 37: saas.accounts.v1.ModuleSubscription.created_at:type_name -> google.protobuf.Timestamp + 44, // 38: saas.accounts.v1.ModuleSubscribeResponse.subscription:type_name -> saas.accounts.v1.ModuleSubscription + 44, // 39: saas.accounts.v1.ModuleListSubscriptionsResponse.subscriptions:type_name -> saas.accounts.v1.ModuleSubscription + 69, // 40: saas.accounts.v1.ModuleReplayEventsRequest.since:type_name -> google.protobuf.Timestamp + 4, // 41: saas.accounts.v1.ReadableSourceCollection.grant_disclosure:type_name -> saas.accounts.v1.MetadataDisclosure + 52, // 42: saas.accounts.v1.ReadableSourceCollection.read_grants:type_name -> saas.accounts.v1.ReadableCollectionGrant + 53, // 43: saas.accounts.v1.ReadableSourceCollection.sync:type_name -> saas.accounts.v1.CollectionSyncProvenance + 69, // 44: saas.accounts.v1.ReadableCollectionGrant.expires_at:type_name -> google.protobuf.Timestamp + 5, // 45: saas.accounts.v1.CollectionSyncProvenance.stage:type_name -> saas.accounts.v1.SourceSyncStage + 69, // 46: saas.accounts.v1.CollectionSyncProvenance.at:type_name -> google.protobuf.Timestamp + 4, // 47: saas.accounts.v1.CollectionSyncProvenance.requester_disclosure:type_name -> saas.accounts.v1.MetadataDisclosure + 69, // 48: saas.accounts.v1.CollectionSyncProvenance.requested_at:type_name -> google.protobuf.Timestamp + 51, // 49: saas.accounts.v1.ListReadableSourceCollectionsResponse.collections:type_name -> saas.accounts.v1.ReadableSourceCollection + 60, // 50: saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedReadAudience:input_type -> saas.accounts.v1.ModuleExchangeDelegatedReadAudienceRequest + 61, // 51: saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedOperationAudience:input_type -> saas.accounts.v1.ModuleExchangeDelegatedOperationAudienceRequest + 58, // 52: saas.accounts.v1.ModuleCapabilitiesService.CheckWorkContextRecordAccess:input_type -> saas.accounts.v1.CheckWorkContextRecordAccessRequest + 54, // 53: saas.accounts.v1.ModuleCapabilitiesService.ListReadableSourceCollections:input_type -> saas.accounts.v1.ListReadableSourceCollectionsRequest + 56, // 54: saas.accounts.v1.ModuleCapabilitiesService.PlaceRecord:input_type -> saas.accounts.v1.ModulePlaceRecordRequest + 6, // 55: saas.accounts.v1.ModuleCapabilitiesService.EnqueueJob:input_type -> saas.accounts.v1.ModuleEnqueueJobRequest + 8, // 56: saas.accounts.v1.ModuleCapabilitiesService.ClaimJobs:input_type -> saas.accounts.v1.ModuleClaimJobsRequest + 10, // 57: saas.accounts.v1.ModuleCapabilitiesService.HeartbeatJob:input_type -> saas.accounts.v1.ModuleHeartbeatJobRequest + 12, // 58: saas.accounts.v1.ModuleCapabilitiesService.AckJob:input_type -> saas.accounts.v1.ModuleAckJobRequest + 13, // 59: saas.accounts.v1.ModuleCapabilitiesService.NackJob:input_type -> saas.accounts.v1.ModuleNackJobRequest + 14, // 60: saas.accounts.v1.ModuleCapabilitiesService.NotifyUser:input_type -> saas.accounts.v1.ModuleNotifyUserRequest + 16, // 61: saas.accounts.v1.ModuleCapabilitiesService.NotifyOrgAdmins:input_type -> saas.accounts.v1.ModuleNotifyOrgAdminsRequest + 20, // 62: saas.accounts.v1.ModuleCapabilitiesService.RequestApproval:input_type -> saas.accounts.v1.ModuleRequestApprovalRequest + 22, // 63: saas.accounts.v1.ModuleCapabilitiesService.GetApproval:input_type -> saas.accounts.v1.ModuleGetApprovalRequest + 24, // 64: saas.accounts.v1.ModuleCapabilitiesService.CancelApproval:input_type -> saas.accounts.v1.ModuleCancelApprovalRequest + 25, // 65: saas.accounts.v1.ModuleCapabilitiesService.EmitAuditEvent:input_type -> saas.accounts.v1.ModuleEmitAuditEventRequest + 28, // 66: saas.accounts.v1.ModuleCapabilitiesService.DeclareAuditEventTypes:input_type -> saas.accounts.v1.ModuleDeclareAuditEventTypesRequest + 31, // 67: saas.accounts.v1.ModuleCapabilitiesService.ListSubjectVisibility:input_type -> saas.accounts.v1.ModuleListSubjectVisibilityRequest + 33, // 68: saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceBlob:input_type -> saas.accounts.v1.FetchDatasourceBlobRequest + 36, // 69: saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceFiles:input_type -> saas.accounts.v1.FetchDatasourceFilesRequest + 72, // 70: saas.accounts.v1.ModuleCapabilitiesService.MintModuleRegistration:input_type -> saas.accounts.v1.ModuleMintRegistrationRequest + 73, // 71: saas.accounts.v1.ModuleCapabilitiesService.MintSolutionRegistration:input_type -> saas.accounts.v1.SolutionMintRegistrationRequest + 74, // 72: saas.accounts.v1.ModuleCapabilitiesService.MintModuleWorkContext:input_type -> saas.accounts.v1.ModuleMintWorkContextRequest + 75, // 73: saas.accounts.v1.ModuleCapabilitiesService.MintModuleOperationContext:input_type -> saas.accounts.v1.ModuleMintOperationContextRequest + 76, // 74: saas.accounts.v1.ModuleCapabilitiesService.MintSourceOperationContext:input_type -> saas.accounts.v1.ModuleMintSourceOperationContextRequest + 41, // 75: saas.accounts.v1.ModuleCapabilitiesService.PublishEvent:input_type -> saas.accounts.v1.ModulePublishEventRequest + 43, // 76: saas.accounts.v1.ModuleCapabilitiesService.Subscribe:input_type -> saas.accounts.v1.ModuleSubscribeRequest + 46, // 77: saas.accounts.v1.ModuleCapabilitiesService.Unsubscribe:input_type -> saas.accounts.v1.ModuleUnsubscribeRequest + 47, // 78: saas.accounts.v1.ModuleCapabilitiesService.ListSubscriptions:input_type -> saas.accounts.v1.ModuleListSubscriptionsRequest + 49, // 79: saas.accounts.v1.ModuleCapabilitiesService.ReplayEvents:input_type -> saas.accounts.v1.ModuleReplayEventsRequest + 77, // 80: saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedReadAudience:output_type -> saas.accounts.v1.IssuedWorkContext + 77, // 81: saas.accounts.v1.ModuleCapabilitiesService.ExchangeDelegatedOperationAudience:output_type -> saas.accounts.v1.IssuedWorkContext + 59, // 82: saas.accounts.v1.ModuleCapabilitiesService.CheckWorkContextRecordAccess:output_type -> saas.accounts.v1.CheckWorkContextRecordAccessResponse + 55, // 83: saas.accounts.v1.ModuleCapabilitiesService.ListReadableSourceCollections:output_type -> saas.accounts.v1.ListReadableSourceCollectionsResponse + 57, // 84: saas.accounts.v1.ModuleCapabilitiesService.PlaceRecord:output_type -> saas.accounts.v1.ModulePlaceRecordResponse + 7, // 85: saas.accounts.v1.ModuleCapabilitiesService.EnqueueJob:output_type -> saas.accounts.v1.ModuleEnqueueJobResponse + 9, // 86: saas.accounts.v1.ModuleCapabilitiesService.ClaimJobs:output_type -> saas.accounts.v1.ModuleClaimJobsResponse + 11, // 87: saas.accounts.v1.ModuleCapabilitiesService.HeartbeatJob:output_type -> saas.accounts.v1.ModuleHeartbeatJobResponse + 78, // 88: saas.accounts.v1.ModuleCapabilitiesService.AckJob:output_type -> google.protobuf.Empty + 78, // 89: saas.accounts.v1.ModuleCapabilitiesService.NackJob:output_type -> google.protobuf.Empty + 15, // 90: saas.accounts.v1.ModuleCapabilitiesService.NotifyUser:output_type -> saas.accounts.v1.ModuleNotifyUserResponse + 17, // 91: saas.accounts.v1.ModuleCapabilitiesService.NotifyOrgAdmins:output_type -> saas.accounts.v1.ModuleNotifyOrgAdminsResponse + 21, // 92: saas.accounts.v1.ModuleCapabilitiesService.RequestApproval:output_type -> saas.accounts.v1.ModuleRequestApprovalResponse + 23, // 93: saas.accounts.v1.ModuleCapabilitiesService.GetApproval:output_type -> saas.accounts.v1.ModuleApproval + 78, // 94: saas.accounts.v1.ModuleCapabilitiesService.CancelApproval:output_type -> google.protobuf.Empty + 78, // 95: saas.accounts.v1.ModuleCapabilitiesService.EmitAuditEvent:output_type -> google.protobuf.Empty + 29, // 96: saas.accounts.v1.ModuleCapabilitiesService.DeclareAuditEventTypes:output_type -> saas.accounts.v1.ModuleDeclareAuditEventTypesResponse + 32, // 97: saas.accounts.v1.ModuleCapabilitiesService.ListSubjectVisibility:output_type -> saas.accounts.v1.ModuleListSubjectVisibilityResponse + 34, // 98: saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceBlob:output_type -> saas.accounts.v1.FetchDatasourceBlobChunk + 40, // 99: saas.accounts.v1.ModuleCapabilitiesService.FetchDatasourceFiles:output_type -> saas.accounts.v1.FetchDatasourceFilesFrame + 79, // 100: saas.accounts.v1.ModuleCapabilitiesService.MintModuleRegistration:output_type -> saas.accounts.v1.ModuleMintRegistrationResponse + 80, // 101: saas.accounts.v1.ModuleCapabilitiesService.MintSolutionRegistration:output_type -> saas.accounts.v1.SolutionMintRegistrationResponse + 81, // 102: saas.accounts.v1.ModuleCapabilitiesService.MintModuleWorkContext:output_type -> saas.accounts.v1.ModuleMintWorkContextResponse + 82, // 103: saas.accounts.v1.ModuleCapabilitiesService.MintModuleOperationContext:output_type -> saas.accounts.v1.ModuleMintOperationContextResponse + 83, // 104: saas.accounts.v1.ModuleCapabilitiesService.MintSourceOperationContext:output_type -> saas.accounts.v1.ModuleMintSourceOperationContextResponse + 42, // 105: saas.accounts.v1.ModuleCapabilitiesService.PublishEvent:output_type -> saas.accounts.v1.ModulePublishEventResponse + 45, // 106: saas.accounts.v1.ModuleCapabilitiesService.Subscribe:output_type -> saas.accounts.v1.ModuleSubscribeResponse + 78, // 107: saas.accounts.v1.ModuleCapabilitiesService.Unsubscribe:output_type -> google.protobuf.Empty + 48, // 108: saas.accounts.v1.ModuleCapabilitiesService.ListSubscriptions:output_type -> saas.accounts.v1.ModuleListSubscriptionsResponse + 50, // 109: saas.accounts.v1.ModuleCapabilitiesService.ReplayEvents:output_type -> saas.accounts.v1.ModuleReplayEventsResponse + 80, // [80:110] is the sub-list for method output_type + 50, // [50:80] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_saas_accounts_v1_module_capabilities_proto_init() } @@ -3274,13 +4790,17 @@ func file_saas_accounts_v1_module_capabilities_proto_init() { return } file_saas_accounts_v1_module_registration_proto_init() + file_saas_accounts_v1_module_capabilities_proto_msgTypes[34].OneofWrappers = []any{ + (*FetchDatasourceFilesFrame_Header)(nil), + (*FetchDatasourceFilesFrame_Data)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_module_capabilities_proto_rawDesc), len(file_saas_accounts_v1_module_capabilities_proto_rawDesc)), - NumEnums: 3, - NumMessages: 40, + NumEnums: 6, + NumMessages: 56, NumExtensions: 0, NumServices: 1, }, diff --git a/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_registration.pb.go b/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_registration.pb.go index 3e3088fe0..026065998 100644 --- a/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_registration.pb.go +++ b/libraries/source-read-sdk/go/gen/saas/accounts/v1/module_registration.pb.go @@ -373,6 +373,393 @@ func (x *ModuleMintWorkContextResponse) GetTenant() string { return "" } +// ModuleMintOperationContextRequest asks for a Work Context addressed to one of +// the calling module's installed operation audiences, with no person present — +// the capability background work (a journal replay, a scheduled refresh) uses to +// call another module's service. It authenticates exactly like +// ModuleMintWorkContextRequest, with the module's identity secret for `prefix`. +// +// The caller names only the binding. Audience, scopes, tenant, actor and +// lifetime are deployment policy: the binding's `headless_scopes`, the tenant +// declared for the module principal, and a short fixed lifetime. A binding that +// declares no headless scopes cannot be minted this way at all. +type ModuleMintOperationContextRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` + Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` + Binding string `protobuf:"bytes,3,opt,name=binding,proto3" json:"binding,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleMintOperationContextRequest) Reset() { + *x = ModuleMintOperationContextRequest{} + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleMintOperationContextRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleMintOperationContextRequest) ProtoMessage() {} + +func (x *ModuleMintOperationContextRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleMintOperationContextRequest.ProtoReflect.Descriptor instead. +func (*ModuleMintOperationContextRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_registration_proto_rawDescGZIP(), []int{6} +} + +func (x *ModuleMintOperationContextRequest) GetPrefix() string { + if x != nil { + return x.Prefix + } + return "" +} + +func (x *ModuleMintOperationContextRequest) GetSecret() string { + if x != nil { + return x.Secret + } + return "" +} + +func (x *ModuleMintOperationContextRequest) GetBinding() string { + if x != nil { + return x.Binding + } + return "" +} + +// ModuleMintOperationContextResponse carries the signed capability and what it +// asserts, so a module can attribute and route its call without parsing it. +type ModuleMintOperationContextResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + PrincipalId string `protobuf:"bytes,3,opt,name=principal_id,json=principalId,proto3" json:"principal_id,omitempty"` + Tenant string `protobuf:"bytes,4,opt,name=tenant,proto3" json:"tenant,omitempty"` + Audience string `protobuf:"bytes,5,opt,name=audience,proto3" json:"audience,omitempty"` + Binding string `protobuf:"bytes,6,opt,name=binding,proto3" json:"binding,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleMintOperationContextResponse) Reset() { + *x = ModuleMintOperationContextResponse{} + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleMintOperationContextResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleMintOperationContextResponse) ProtoMessage() {} + +func (x *ModuleMintOperationContextResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleMintOperationContextResponse.ProtoReflect.Descriptor instead. +func (*ModuleMintOperationContextResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_registration_proto_rawDescGZIP(), []int{7} +} + +func (x *ModuleMintOperationContextResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *ModuleMintOperationContextResponse) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *ModuleMintOperationContextResponse) GetPrincipalId() string { + if x != nil { + return x.PrincipalId + } + return "" +} + +func (x *ModuleMintOperationContextResponse) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ModuleMintOperationContextResponse) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +func (x *ModuleMintOperationContextResponse) GetBinding() string { + if x != nil { + return x.Binding + } + return "" +} + +// ModuleMintSourceOperationContextRequest asks for a Work Context that runs one +// datasource source's sync with the authority a person delegated when they +// connected (or reconnected) the source. It authenticates exactly like +// ModuleMintWorkContextRequest, with the module's identity secret for `prefix`. +// +// The caller names the delegation — by its id, or by the source whose active +// delegation to this module is meant — and nothing else. Tenant, owner, +// audience, scopes and lifetime come from the delegation and the binding it +// names: the source's organization, the person who connected it, and exactly +// that binding's `source_delegation_scopes`. +type ModuleMintSourceOperationContextRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` + Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` + // Types that are valid to be assigned to Delegation: + // + // *ModuleMintSourceOperationContextRequest_DelegationId + // *ModuleMintSourceOperationContextRequest_SourceId + Delegation isModuleMintSourceOperationContextRequest_Delegation `protobuf_oneof:"delegation"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleMintSourceOperationContextRequest) Reset() { + *x = ModuleMintSourceOperationContextRequest{} + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleMintSourceOperationContextRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleMintSourceOperationContextRequest) ProtoMessage() {} + +func (x *ModuleMintSourceOperationContextRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleMintSourceOperationContextRequest.ProtoReflect.Descriptor instead. +func (*ModuleMintSourceOperationContextRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_registration_proto_rawDescGZIP(), []int{8} +} + +func (x *ModuleMintSourceOperationContextRequest) GetPrefix() string { + if x != nil { + return x.Prefix + } + return "" +} + +func (x *ModuleMintSourceOperationContextRequest) GetSecret() string { + if x != nil { + return x.Secret + } + return "" +} + +func (x *ModuleMintSourceOperationContextRequest) GetDelegation() isModuleMintSourceOperationContextRequest_Delegation { + if x != nil { + return x.Delegation + } + return nil +} + +func (x *ModuleMintSourceOperationContextRequest) GetDelegationId() string { + if x != nil { + if x, ok := x.Delegation.(*ModuleMintSourceOperationContextRequest_DelegationId); ok { + return x.DelegationId + } + } + return "" +} + +func (x *ModuleMintSourceOperationContextRequest) GetSourceId() string { + if x != nil { + if x, ok := x.Delegation.(*ModuleMintSourceOperationContextRequest_SourceId); ok { + return x.SourceId + } + } + return "" +} + +type isModuleMintSourceOperationContextRequest_Delegation interface { + isModuleMintSourceOperationContextRequest_Delegation() +} + +type ModuleMintSourceOperationContextRequest_DelegationId struct { + // delegation_id names one delegation. A revoked one is refused with + // PERMISSION_DENIED; one that does not exist or belongs to another module + // is refused the same way, indistinguishably. The module's declared tenant + // and cross_tenant grant play no part: the delegation authorizes its org. + DelegationId string `protobuf:"bytes,3,opt,name=delegation_id,json=delegationId,proto3,oneof"` +} + +type ModuleMintSourceOperationContextRequest_SourceId struct { + // source_id names a source; its active delegation to the calling module is + // used. A source with none is FAILED_PRECONDITION, reason + // DELEGATION_MISSING: a person must connect or reconnect it. + SourceId string `protobuf:"bytes,4,opt,name=source_id,json=sourceId,proto3,oneof"` +} + +func (*ModuleMintSourceOperationContextRequest_DelegationId) isModuleMintSourceOperationContextRequest_Delegation() { +} + +func (*ModuleMintSourceOperationContextRequest_SourceId) isModuleMintSourceOperationContextRequest_Delegation() { +} + +// ModuleMintSourceOperationContextResponse carries the signed capability and +// what it asserts, so a module can attribute and route its call without +// parsing it. +type ModuleMintSourceOperationContextResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + // principal_id is the calling module's service principal: the actor. + PrincipalId string `protobuf:"bytes,3,opt,name=principal_id,json=principalId,proto3" json:"principal_id,omitempty"` + // tenant is the source's organization. + Tenant string `protobuf:"bytes,4,opt,name=tenant,proto3" json:"tenant,omitempty"` + Audience string `protobuf:"bytes,5,opt,name=audience,proto3" json:"audience,omitempty"` + Binding string `protobuf:"bytes,6,opt,name=binding,proto3" json:"binding,omitempty"` + DelegationId string `protobuf:"bytes,7,opt,name=delegation_id,json=delegationId,proto3" json:"delegation_id,omitempty"` + SourceId string `protobuf:"bytes,8,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + // owner_principal_id is the person who delegated the source: the owner. + OwnerPrincipalId string `protobuf:"bytes,9,opt,name=owner_principal_id,json=ownerPrincipalId,proto3" json:"owner_principal_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleMintSourceOperationContextResponse) Reset() { + *x = ModuleMintSourceOperationContextResponse{} + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleMintSourceOperationContextResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleMintSourceOperationContextResponse) ProtoMessage() {} + +func (x *ModuleMintSourceOperationContextResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_module_registration_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleMintSourceOperationContextResponse.ProtoReflect.Descriptor instead. +func (*ModuleMintSourceOperationContextResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_module_registration_proto_rawDescGZIP(), []int{9} +} + +func (x *ModuleMintSourceOperationContextResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *ModuleMintSourceOperationContextResponse) GetPrincipalId() string { + if x != nil { + return x.PrincipalId + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetBinding() string { + if x != nil { + return x.Binding + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetDelegationId() string { + if x != nil { + return x.DelegationId + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *ModuleMintSourceOperationContextResponse) GetOwnerPrincipalId() string { + if x != nil { + return x.OwnerPrincipalId + } + return "" +} + var File_saas_accounts_v1_module_registration_proto protoreflect.FileDescriptor const file_saas_accounts_v1_module_registration_proto_rawDesc = "" + @@ -404,7 +791,40 @@ const file_saas_accounts_v1_module_registration_proto_rawDesc = "" + "\n" + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12!\n" + "\fprincipal_id\x18\x03 \x01(\tR\vprincipalId\x12\x16\n" + - "\x06tenant\x18\x04 \x01(\tR\x06tenantb\x06proto3" + "\x06tenant\x18\x04 \x01(\tR\x06tenant\"\xb3\x01\n" + + "!ModuleMintOperationContextRequest\x12D\n" + + "\x06prefix\x18\x01 \x01(\tB,\xbaH)r'\x10\x01\x18?2!^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$R\x06prefix\x12\"\n" + + "\x06secret\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x06secret\x12$\n" + + "\abinding\x18\x03 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\abinding\"\xe6\x01\n" + + "\"ModuleMintOperationContextResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x129\n" + + "\n" + + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12!\n" + + "\fprincipal_id\x18\x03 \x01(\tR\vprincipalId\x12\x16\n" + + "\x06tenant\x18\x04 \x01(\tR\x06tenant\x12\x1a\n" + + "\baudience\x18\x05 \x01(\tR\baudience\x12\x18\n" + + "\abinding\x18\x06 \x01(\tR\abinding\"\x82\x02\n" + + "'ModuleMintSourceOperationContextRequest\x12D\n" + + "\x06prefix\x18\x01 \x01(\tB,\xbaH)r'\x10\x01\x18?2!^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$R\x06prefix\x12\"\n" + + "\x06secret\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x06secret\x12/\n" + + "\rdelegation_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01H\x00R\fdelegationId\x12'\n" + + "\tsource_id\x18\x04 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01H\x00R\bsourceIdB\x13\n" + + "\n" + + "delegation\x12\x05\xbaH\x02\b\x01\"\xdc\x02\n" + + "(ModuleMintSourceOperationContextResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x129\n" + + "\n" + + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12!\n" + + "\fprincipal_id\x18\x03 \x01(\tR\vprincipalId\x12\x16\n" + + "\x06tenant\x18\x04 \x01(\tR\x06tenant\x12\x1a\n" + + "\baudience\x18\x05 \x01(\tR\baudience\x12\x18\n" + + "\abinding\x18\x06 \x01(\tR\abinding\x12#\n" + + "\rdelegation_id\x18\a \x01(\tR\fdelegationId\x12\x1b\n" + + "\tsource_id\x18\b \x01(\tR\bsourceId\x12,\n" + + "\x12owner_principal_id\x18\t \x01(\tR\x10ownerPrincipalIdb\x06proto3" var ( file_saas_accounts_v1_module_registration_proto_rawDescOnce sync.Once @@ -418,25 +838,31 @@ func file_saas_accounts_v1_module_registration_proto_rawDescGZIP() []byte { return file_saas_accounts_v1_module_registration_proto_rawDescData } -var file_saas_accounts_v1_module_registration_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_saas_accounts_v1_module_registration_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_saas_accounts_v1_module_registration_proto_goTypes = []any{ - (*ModuleMintRegistrationRequest)(nil), // 0: saas.accounts.v1.ModuleMintRegistrationRequest - (*ModuleMintRegistrationResponse)(nil), // 1: saas.accounts.v1.ModuleMintRegistrationResponse - (*SolutionMintRegistrationRequest)(nil), // 2: saas.accounts.v1.SolutionMintRegistrationRequest - (*SolutionMintRegistrationResponse)(nil), // 3: saas.accounts.v1.SolutionMintRegistrationResponse - (*ModuleMintWorkContextRequest)(nil), // 4: saas.accounts.v1.ModuleMintWorkContextRequest - (*ModuleMintWorkContextResponse)(nil), // 5: saas.accounts.v1.ModuleMintWorkContextResponse - (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp + (*ModuleMintRegistrationRequest)(nil), // 0: saas.accounts.v1.ModuleMintRegistrationRequest + (*ModuleMintRegistrationResponse)(nil), // 1: saas.accounts.v1.ModuleMintRegistrationResponse + (*SolutionMintRegistrationRequest)(nil), // 2: saas.accounts.v1.SolutionMintRegistrationRequest + (*SolutionMintRegistrationResponse)(nil), // 3: saas.accounts.v1.SolutionMintRegistrationResponse + (*ModuleMintWorkContextRequest)(nil), // 4: saas.accounts.v1.ModuleMintWorkContextRequest + (*ModuleMintWorkContextResponse)(nil), // 5: saas.accounts.v1.ModuleMintWorkContextResponse + (*ModuleMintOperationContextRequest)(nil), // 6: saas.accounts.v1.ModuleMintOperationContextRequest + (*ModuleMintOperationContextResponse)(nil), // 7: saas.accounts.v1.ModuleMintOperationContextResponse + (*ModuleMintSourceOperationContextRequest)(nil), // 8: saas.accounts.v1.ModuleMintSourceOperationContextRequest + (*ModuleMintSourceOperationContextResponse)(nil), // 9: saas.accounts.v1.ModuleMintSourceOperationContextResponse + (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp } var file_saas_accounts_v1_module_registration_proto_depIdxs = []int32{ - 6, // 0: saas.accounts.v1.ModuleMintRegistrationResponse.expires_at:type_name -> google.protobuf.Timestamp - 6, // 1: saas.accounts.v1.SolutionMintRegistrationResponse.expires_at:type_name -> google.protobuf.Timestamp - 6, // 2: saas.accounts.v1.ModuleMintWorkContextResponse.expires_at:type_name -> google.protobuf.Timestamp - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 10, // 0: saas.accounts.v1.ModuleMintRegistrationResponse.expires_at:type_name -> google.protobuf.Timestamp + 10, // 1: saas.accounts.v1.SolutionMintRegistrationResponse.expires_at:type_name -> google.protobuf.Timestamp + 10, // 2: saas.accounts.v1.ModuleMintWorkContextResponse.expires_at:type_name -> google.protobuf.Timestamp + 10, // 3: saas.accounts.v1.ModuleMintOperationContextResponse.expires_at:type_name -> google.protobuf.Timestamp + 10, // 4: saas.accounts.v1.ModuleMintSourceOperationContextResponse.expires_at:type_name -> google.protobuf.Timestamp + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_saas_accounts_v1_module_registration_proto_init() } @@ -444,13 +870,17 @@ func file_saas_accounts_v1_module_registration_proto_init() { if File_saas_accounts_v1_module_registration_proto != nil { return } + file_saas_accounts_v1_module_registration_proto_msgTypes[8].OneofWrappers = []any{ + (*ModuleMintSourceOperationContextRequest_DelegationId)(nil), + (*ModuleMintSourceOperationContextRequest_SourceId)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_module_registration_proto_rawDesc), len(file_saas_accounts_v1_module_registration_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/libraries/source-read-sdk/library.codefly.yaml b/libraries/source-read-sdk/library.codefly.yaml index 9908aaa07..6056b129c 100644 --- a/libraries/source-read-sdk/library.codefly.yaml +++ b/libraries/source-read-sdk/library.codefly.yaml @@ -11,10 +11,10 @@ sources: version: 0.1.0 service: accounts endpoint: connect - contract-digest: sha256:0e4456c65985a563695d7408fa2d201e6d1a69752288cce3edc6ca79948579d0 + contract-digest: sha256:b8ef1ba87e5d9b96c067194fa0fb07892c2ba937c623e54a8c5290cc6609cab8 services: - ModuleCapabilitiesService generated-by: - codefly: development - companion: ghcr.io/codefly-dev/proto:0.0.14 + codefly: 0.1.168 + companion: ghcr.io/codefly-dev/proto:0.0.16 facade: true diff --git a/module/AGENTS.md b/module/AGENTS.md index f89e7041a..96431ad7f 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -13,7 +13,7 @@ Read the repository-root `AGENTS.md` first for ownership, boundaries and the behavioural rules (in a consumer, read your own). [SERVICE_CATALOG.md](./SERVICE_CATALOG.md) and [DEPLOYMENT_TOPOLOGY.md](./DEPLOYMENT_TOPOLOGY.md) describe the graph; -[services/README.md](./services/README.md) introduces the eight services. +[services/README.md](./services/README.md) introduces the services; `module.codefly.yaml` is the inventory they are counted from, so neither doc states a number. ## Generated files are never edited by hand diff --git a/module/DATABASE_AUTHORITY.md b/module/DATABASE_AUTHORITY.md index 0a562554d..0acc7da59 100644 --- a/module/DATABASE_AUTHORITY.md +++ b/module/DATABASE_AUTHORITY.md @@ -525,7 +525,7 @@ executable inventory and this table in the same change. | Scope | Relations | Required database boundary | |---|---|---| -| `global` | `audit_event_types`, `bootstrap_state`, `data_retention_policies`, `datasource_credential_budgets`, `email_templates`, `feature_flags`, `identity_providers`, `plan_entitlements`, `plans`, `platform_admins`, `solution_registrations` | No RLS; exact grants | +| `global` | `audit_event_types`, `bootstrap_state`, `data_retention_policies`, `datasource_credential_budgets`, `email_templates`, `feature_flags`, `identity_providers`, `plan_entitlements`, `plans`, `platform_admins`, `policy_log_commits`, `policy_log_cursor`, `solution_delivery_documents`, `solution_generation_history`, `solution_host_bindings`, `solution_registrations`, `solution_targets` | No RLS; exact grants | | `tenant` | `actor_chain_journal`, `actor_chain_revocations`, `api_keys`, `approval_decisions`, `approval_requests`, `audit_event_idempotency`, `audit_events`, `connector_credentials`, `dashboards`, `datasource_account_links`, `datasource_domains`, `datasource_group_bindings`, `datasource_sources`, `delegation_grants`, `domain_events`, `entitlement_overrides`, `executable_artifact_approvals`, `github_app_installations`, `github_app_setups`, `installations`, `invitations`, `membership_integrity_findings`, `org_generic_settings`, `org_identity_providers`, `org_settings`, `organization_activations`, `organization_authorization_revisions`, `organization_members`, `organizations`, `principal_authorization_revisions`, `principals`, `record_shares`, `role_assignments`, `role_permissions`, `roles`, `scope_grants`, `scope_nodes`, `source_delegations`, `source_read_revisions`, `subscriptions`, `team_members`, `team_membership_quarantine`, `teams`, `usage_events`, `usage_totals`, `webhook_deliveries`, `webhook_subscriptions`, `work_context_replay` | Enabled and forced RLS with at least one policy | | `user` | `client_authorization_codes`, `gdpr_requests`, `mfa_backup_codes`, `mfa_devices`, `mfa_login_transactions`, `notifications`, `onboarding_progress`, `resource_follows`, `sessions`, `user_consent_events`, `user_consent_preferences`, `user_identities`, `users`, `webauthn_ceremonies`, `webauthn_credentials` | Enabled and forced RLS with at least one policy | | `pre_auth` | `magic_links`, `waitlist_entries` | Enabled and forced RLS; fail-closed request policy, accessed only by the control-plane role | @@ -890,6 +890,125 @@ creation/activity timestamps, idle expiry, and absolute expiry are unchanged. This lock order serializes switch-versus-refresh races without treating a stale access-token session id as refresh replay. +## The policy log, and why it is not in this database + +Every narrowing of authority — a revocation, a tenancy change, a tightened +envelope — and every **authorised regrant** is appended to an **external** +append-only log before it takes effect here, and the host keeps only the +receipt. + +The reason it cannot live in this database is the whole point. A transactional +audit event plus an append-only table beside the state they describe are +**backed up with that state**, so restoring the database restores revoked +authority together with the history that was supposed to witness against it. A +history that is restored alongside what it witnesses cannot witness. + +**The protocol is append → receipt → commit-with-receipt**, and the ordering is +load-bearing in one direction: + +| failure | state afterwards | why it is safe | +| --- | --- | --- | +| the append fails | nothing narrowed, caller refused | authority was not reduced and nobody was told it was | +| the append lands, the commit fails | **the host stops serving** | the log says the narrowing happened; serving the wider authority still held locally would be serving authority that was revoked | +| the commit lands without an append | **impossible** | the append comes first and its receipt is required by the commit | + +Two relations hold the local half. `policy_log_commits` is one row per +operation — the operation id is the **caller's** idempotency key, because only +the caller knows two attempts are the same revocation — carrying the receipt and +a `committed_at` that is NULL exactly while the gap is open. `policy_log_cursor` +is a single row recording how far this host has reconciled the log and when it +last reached it. Neither is deletable: a receipt is the evidence that an append +happened, so deleting one would make an unreconciled gap **disappear** rather +than be closed, which is the one way the fail-closed asymmetry could be defeated +locally. + +**Serving is gated on two conditions, checked on the serving path and not only at +startup.** A gap opens at runtime — a commit that failed after its append landed +— so a startup-only check would serve through exactly the window the protocol +exists for. The host refuses when it holds an unapplied logged operation, and +when it has not reached the log within a bounded staleness window: a host that +cannot read the log does not know whether its authority is current, and serving +what it last believed is serving authority that may have been revoked since. + +The staleness window is deliberately not zero. A log round trip per request +would make the log's availability the host's own, and a momentary blip would +become an outage. It is deliberately not unbounded either, because then "I +cannot reach the log" would never become "I must stop serving". + +**The append is bounded, and the bound is about locks rather than latency.** It +happens before the narrowing's transaction opens, so a slow log cannot hold +database locks — but a caller blocked indefinitely still holds its request and +everything above it. The bound converts that into a refusal the caller can act +on, and a narrowing that cannot be logged must not proceed. + +**Reconciliation reports gaps; it does not decide authority from the log.** +Applying an entry means re-running the narrowing it describes, which is domain +logic — a reconciler that re-derived authority from log payloads would be a +second implementation of every narrowing, and the copy is what drifts. + +### What goes through it, and what the host does without it + +**Four narrowings route through the protocol**, each appending before it applies +and committing its receipt in the transaction that applies it: revoking an +installation (`UninstallSolution`), closing a withdrawn solution target — which +revokes every installation naming it — revoking a hierarchical scope grant, and +removing a team membership. Installing, granting and adding are not narrowings +and do not appear in the log; an authorised **regrant** does, because a log that +recorded only reductions could not tell "this authority was restored" from "it +was never taken away". + +That transaction is a **control-plane** one even for an organisation-scoped +narrowing, because `policy_log_commits` is control-plane only and the receipt and +the narrowing must be the same transaction or "applied but unwitnessed" becomes +reachable. The tenant policy that would otherwise have confined each statement is +replaced by an explicit predicate at the call site: three of the four already +name their organisation in the statement, and the fourth — a team membership, +whose delete is keyed on (team, user) and carries no organisation column — +re-reads the team's owning organisation on the same transaction and refuses a +mismatch. + +The **serving gate runs on the request path**, in both transports' authorization +interceptors, after authorization so the refusal reaches only callers the host +would have served. Its answer is reused for a bounded window rather than +recomputed per request — the gate is a control-plane read — and the window hides +nothing: a gap this host opens itself drops the cached answer at the instant the +append is recorded, and a gap another replica opened arrives through +reconciliation, whose interval is longer than the window. A host that cannot +evaluate the gate refuses, because "I could not check" must not be served as +"there is nothing to find". The refusal is **Unavailable and never +PermissionDenied**: the caller is not unauthorized, the host is unable, and a +host answering "forbidden" would tell every caller their authority had been +revoked when nothing of theirs had. + +### The independent policy-log service + +The transport owner is the **[policy-log service](services/policy-log/README.md); +the host wires its client**. Its witness assigns a global sequence and returns +a signed receipt only after an immutable entry and a generation-guarded object-store +head have committed in the warehouse's trust domain. Accounts holds neither +that store's write authority nor the signing key. Composition delivers +`policy_log_public_key.json` under the host's trust-anchor directory. + +The warehouse remains a best-effort mirror, never the source of the receipt. +A BigQuery writer gets no global ordinal from streaming inserts or per-stream +offsets, cannot wait for ingestion visibility within the five-second bound, +and deliberately holds no read or job-creation permission to recover its row. +Those limitations are why the receipt issuer is a separate service rather than +an accounts adapter that manufactures its own evidence. + +**This checkout does not yet have a runnable log transport.** The new service's +witness, storage, signing-key loader, mirror, and private service declaration are +implemented; protobuf and OpenAPI generation and the RPC listener remain blocked +by the required generator prerequisite. Its README records that boundary and +the remaining real-GCS qualification. The host's Go code and client wiring are +unchanged, so this source addition must not be read as a running deployment or +as completion of the policy-log condition. + +Until the transport and host client are wired, the existing host still serves +normally and refuses the four narrowings above. Once wired, inability to reach +the log expires the serving gate's freshness window and refuses service; an +appended operation without a local commit remains a gap until reconciled. + ## Control-plane boundary Migrations `67_control_plane_role` and `68_remove_policy_guc_bypass` replace diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index 90a39df12..76a180eba 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -21,11 +21,22 @@ anywhere else. | `services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml` | Test-only mesh-policy golden (STRICT mTLS + the internal-authority and internal-HTTP AuthorizationPolicies + waypoint); mirrors those resources from the per-environment GitOps mesh baseline, not the whole of it — the namespace `default-deny` and the L4 internal policies are rendered there only. | | `services/accounts/code/pkg/cataloggen/deployment_topology.go` | Strict compiler, semantic validator, and renderers. | -The normalized inventory currently contains eight services, twelve endpoints, -eight dependency edges, four module-interface endpoints, and four explicit -public-egress grants (`deployment_topology_test.go` pins the counts). The accounts descriptor catalog is an input: if its RPCs -use gRPC, Connect, or REST without a corresponding accounts endpoint, -generation fails. +`deployment_topology_test.go` pins the normalized inventory's shape — how many +services, endpoints, dependency edges, module-interface endpoints and explicit +public-egress grants it contains — and that test is the authority. The numbers +are deliberately NOT repeated here: this paragraph stated five of them and every +one was stale, because a count in prose is enforced by nothing and a service +added elsewhere does not touch it. Read them from the assertions. + +The accounts descriptor catalog is an input: if its RPCs use gRPC, Connect, or +REST without a corresponding accounts endpoint, generation fails. + +`policy-log` is in the inventory with one private `grpc` endpoint and appears in +NO row of the service graph below, which is correct rather than an omission: it +declares no dependency on anything, and the host's client for it is not built +yet, so there is no edge to draw. When that client lands it adds an +`accounts` → `policy-log/grpc` row and nothing else — the witness must never +depend on the database whose authority it witnesses. ## Service graph @@ -178,7 +189,8 @@ service's declared in-mesh callers (`notPrincipals`). It is a different resource from the internal-authority ALLOW above and gates a different surface; the two share only the waypoint that evaluates them. Istio evaluates DENY before ALLOW, so it subtracts from the port-wide grant; other methods on the same path — the -unauthenticated `GET /api/solutions/register` the sidebar polls — are untouched, +`GET /api/solutions/register` the sidebar polls, which is authenticated on the +viewer's own bearer rather than on this DENY (issue #949) — are untouched, as is the proxy's own loopback read of `/api/internal/solutions`, which never leaves the pod and so is never captured by the mesh. Each path is emitted twice, exactly and as a `*`-prefixed suffix pattern, because Istio's default path diff --git a/module/FEATURES.md b/module/FEATURES.md index 7753e98bf..2e3ff8ecf 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -595,7 +595,7 @@ _All previously-open gaps closed 2026-04-25._ - ✅ **GetOrgEntitlements had no authz** — any authenticated user could read any org's plan + usage. Now gated by `requireOrgMember` (platform admins implicitly satisfy via membership). - ✅ **OverrideEntitlement had no authz** — implicit JWT-only. Now requires `platformAdmin`. - ✅ **API key scopes enforced on more endpoints** — webhooks (Create/Delete/List/Test/GetDelivery/ReplayDelivery/RotateSecret) and api-keys (Create/List/Revoke). 5 new unit tests pin the wildcard semantics + the JWT pass-through. -- ✅ **Self-serve SSO admin (WorkOS Connections)** — proto SSOAdminService + business / handler / WorkOS HTTP client + migration 21 + /admin/sso FE. Stub-mode when the selected Codefly identity configuration has no `IDENTITY_MANAGEMENT_API_KEY`, so dev exercises the full flow. +- ✅ **Self-serve SSO admin (WorkOS Connections)** — proto SSOAdminService + business / handler / WorkOS HTTP client + migration 23 + /admin/sso FE. Stub-mode when the selected Codefly identity configuration has no `IDENTITY_MANAGEMENT_API_KEY`, so dev exercises the full flow. - ✅ **Audit-export FE admin form** — was backend-only; /admin/audit-export now lets org admins configure their bucket through the UI. Pre-flight connection probe at Save time so bad creds fail fast. - ✅ **s3 plugin now actually runs MinIO** — was a redis-template scaffold (port 6379, redis ping readiness); now real (port 9000, /minio/health/live, structured conn keys, agent v0.0.2). - ✅ **User settings API** — JSONB-backed (`users.settings`) + UserSettingsService + /settings hub (theme / locale / timezone / date-time format / email opt-ins). diff --git a/module/GATEWAY_ROUTES.md b/module/GATEWAY_ROUTES.md index fe43449f3..dc842b7ab 100644 --- a/module/GATEWAY_ROUTES.md +++ b/module/GATEWAY_ROUTES.md @@ -107,34 +107,10 @@ the two GitHub delivery receivers and for `/v1/status`. `TestRegisteredHTTPRoutesAreRoutedAtTheGateway` in `module/tools` is the correspondence check. -## Prefixes the catalog reserves - -Each `/v1/` the catalog owns — generated routes and explicit extensions -alike — is a prefix a runtime-registered module may never claim: a colliding -`/modules/_register` is refused with 409, because the catalog always wins in the -matcher and a stored-but-shadowed registration would be a silently dead route. -Registrations live in the gateway process, so the refusal bites on the next -restart of either side: a module that holds such a prefix today goes dark and -cannot re-register. - -That makes the reserved set a contract with every downstream composer, so it is -pinned by `TestReservedV1PrefixesArePinned` rather than derived silently from -whatever paths the catalog happens to contain. Adding a route under a new first -segment narrows what composed modules may register; mount it under a prefix the -catalog already owns, or widen the pinned list deliberately. The current set: +## Module REST routes -``` -.well-known accessible-scopes acquisition api-keys -audit-event-types audit-log auth billing -collection-access consent datasource delegations -email gdpr installations invitations -mfa notifications organizations platform -principals public record-shares resource-follows -role-assignments roles scope-grants scope-nodes -sso status subscriptions teams -user users version waitlist -webhooks work-contexts -``` +The generated and explicit route catalogs own the complete module REST surface. +Unknown paths return 404. Module workloads cannot add a proxy target at runtime. `saas.frontend.plugins.v1` catalogs all 36 Next.js pages and the admin plugin catch-all. The environment topology generator owns the deployed ingress diff --git a/module/MODULE_INSTALLATION.md b/module/MODULE_INSTALLATION.md index 1550f99da..802dc4038 100644 --- a/module/MODULE_INSTALLATION.md +++ b/module/MODULE_INSTALLATION.md @@ -244,8 +244,10 @@ current policy checks still happen through the existing runtime mechanisms. The versioned hash input is one JSON object with these exact keys: - `schema_version`, set to the requested supported version; -- `module_id`, `agent_identifier`, `solution_identifier`, - from the authorized request; +- `module_id`, `agent_identifier`, `target_id`, + from the authorized request. `target_id` is the immutable solution target the + installation is for; at `/v1` this was `solution_identifier`, a free-text route + alias, and replacing it is the field change the version step below records. - `organization_id`, `principal_id`, `installation_id`, `scope_node_id`, `grant_id`, from the verified persisted installation; - `owner_principal_id`, `role_id`, `role_permissions`, `allowed_audiences`, @@ -261,6 +263,13 @@ hex digest. The golden test pins the encoded contract independently of the Go encoder. Consumers use the opaque server result; they do not each reimplement this calculation. A change to these fields or encoding requires a schema decision. +The current version is **`accounts.module-installation-authority/v2`**. `v1` +named `solution_identifier`; a route alias is reusable by a later binding, so an +authority reference built on it did not identify one installation over time. `v2` +names `target_id` instead. The version string and the field moved together, which +is what the schema decision means here: a consumer that pinned `v1` is refused by +name rather than handed a digest over different fields. + Software versions, image references, full deployment approval digests, display labels, organization lookup spelling, installer credential material and delegation expiry are absent. Organization identity uses the verified UUID, so case aliases diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index b85649719..fbaa0efed 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -1,10 +1,11 @@ # Solution registration: trust model, authority, and compatibility A **solution** is an independently deployed module this host has no build-time -knowledge of. It registers itself at runtime in two places — a gateway upstream -for `/solutions/{id}/…`, and a Module-Federation remote the frontend loads on -`/s/{id}`. This document states what registering a solution is *permitted to -mean*, who may do it, and what the host checks before any of it takes effect. +knowledge of. Delivery declares its presence through a signed +`SolutionHostBinding`; the host reconciles that declaration into durable state. +The gateway reads that state for `/solutions/{id}/…`, and the frontend reads it +for `/s/{id}`. This document states who may declare presence, what the host +checks, and how presence differs from a tenant's authority to use a solution. ## 1. The supported trust model @@ -34,136 +35,30 @@ Three mechanisms are routinely mistaken for a sandbox. None of them is one: tenant and role. It cannot constrain in-origin code acting with the current user's own credentials, because that code is indistinguishable from the user. -## 2. One owner-bound authority contract - -Registration is authorized by a short-lived credential bound to the publisher and -to the single solution id it may act on. The gateway and the frontend accept the -**same** credential and verify it independently; neither holds a secret every -registrant shares, and neither trusts the other's decision. - -The handshake mirrors the composed-module one -(`services/auth-gateway/AGENTS.md`, "Composed-module REST federation") and -reuses its issuer rather than adding a second one: - -1. `POST /solutions/_registration-token` on the auth-gateway, carrying the - cluster-internal token in `X-Codefly-Internal-Token` (a perimeter check, not - the authorization) **and** the solution's own registration secret in - `X-Codefly-Solution-Secret`, body `{"id": ""}`. -2. The gateway brokers to accounts over the internal listener - (`ModuleCapabilitiesService/MintSolutionRegistration`, `EXPOSURE_INTERNAL`, so - the generated mesh policy admits the gateway's service account and denies - everyone else). accounts compares the presented secret against the digest - declared for **that id** in the `federation` group's - `SOLUTION_REGISTRATION_SECRETS`, and on a match mints a 5-minute Ed25519 - token — `aud=solution-registration`, `sub=solution:`, `solution=`, - with a `jti` — emitting a `saas.solution.registration_minted` audit event. - Unset means **no solution may register**. - - That declaration is read **on every exchange**, not held from startup, so - accounts never has to restart to see an edit: authorizing a publisher takes - effect on its next exchange, and withdrawing one stops its next renewal — - after which the gateway's 120-second lease runs out and the registration - stops serving. A declaration that stops parsing denies every solution, with - the same refusal a wrong secret gets and the reason in the operator's log. - `MODULE_REGISTRATION_SECRETS` beside it is deliberately not treated this way: - a module is composed into this host's build, so its declaration cannot change - without a new deployment anyway. - - **What that does not yet give you, stated plainly because a revocation - depends on it.** accounts reads the declaration through the Codefly SDK, - which resolves it from the process environment and the runtime's injected - carrier. Both are live reads — an edit is observed the moment it reaches - them — but **no runtime component updates either one under a running - process** (`codefly-dev/cli#740`). So in a deployment that delivers this key - as a container environment variable, an edit does not reach accounts at all, - and withdrawing a publisher still requires restarting the service. Until that - issue closes, treat a revocation as taking effect on restart, not on edit, - and do not rely on removing a digest to lock out a publisher whose secret you - believe is compromised — delete its registration and restart accounts. -3. The solution presents that token in `X-Codefly-Solution-Registration` to both - `POST /solutions/_register` (gateway, `{id, upstream}`) and - `POST /api/solutions/register` (frontend, the manifest). `DELETE` on either - withdraws it. - -The exchange keeps three outcomes apart, because a registrant acts differently -on each: `401` means accounts refused the secret — the only answer that means -"check `SOLUTION_REGISTRATION_SECRETS` and the provisioned secret"; `503` with -`Retry-After` means accounts could not be reached or did not answer in time (a -rollout, a restart) and the next attempt may succeed; `502` means accounts -answered with an error that is neither. The frontend likewise answers `503`, not -`401`, when it cannot reach the key set it verifies the token against. A -registrant must not read any 5xx — including a `502` from an ingress or mesh in -front of a restarting gateway — as a provisioning fault. - -`SOLUTION_REGISTRATION_SECRETS` is declared **separately** from -`MODULE_REGISTRATION_SECRETS`, and the two credentials carry different -audiences. A module credential federates a REST prefix; a solution credential -additionally publishes host-origin code. Holding one must never confer the -other, so neither the configuration nor the token is shared. - -### What each half enforces - -| Check | Gateway | Frontend | -| --- | --- | --- | -| Ed25519 signature against the published JWKS, alg-locked to EdDSA | yes | yes | -| Issuer, `solution-registration` audience, expiry (with skew leeway) | yes | yes | -| `solution` claim equals the id in the request | yes (403) | yes (403) | -| First registration binds the id to `sub`; a different `sub` is refused | yes (409) | yes (409) | -| Same publisher may move its own endpoint | yes | yes | -| Delete is owner-bound, and a non-owner's delete is indistinguishable from an unknown id | yes | yes | -| `jti` burned on use, so a captured credential cannot be replayed | yes | yes | -| Request body bounded before parsing | yes (256 KiB; the separate token exchange bounds at 4 KiB) | no bound of its own — the manifest is bounded where the gateway accepts it | -| Id is one catalog-identity segment, so reserved `_…` sub-paths cannot be shadowed | yes | n/a (id comes from the manifest and must equal the claim) | -| Upstream host must be composition-local; resolved address re-checked at dial time | yes | n/a | - -The gateway deliberately allows a solution to **move** its upstream, which -module federation does not. That is safe only because the publisher is proven on -every call: an endpoint legitimately changes on a redeploy or a new dev port, -while a credential holder for one solution can neither claim another's id nor -redirect it. - -### Upstream constraints and the development exception - -A solution upstream receives forwarded user bearers, so it is held to the same -rule as a federated module upstream: loopback and private ranges (the dev and -in-cluster cases), mesh short names and cluster suffixes — and nothing globally -routable, no link-local, no cloud-metadata address. The register-time host string -is only half of it: the **resolved address is re-checked at dial time**, so a -mesh-looking name whose DNS answer later points off-mesh never receives a -forwarded bearer. Loopback is an explicit development exception and is tested as -such. - -## 3. Runtime compatibility is enforced, not stored - -A declared requirement the host does not check is not a compatibility contract. -Every requirement is checked **before activation**, so an incompatible remote is -never handed to a browser. An undeclared major defaults to `1` — the major in -force when the field was introduced, which is what silence actually asserts — and -never to the host's current major, which would make every silent manifest -compatible by definition at exactly the upgrade this check exists for: - -| Manifest field | Checked against | Default when absent | -| --- | --- | --- | -| `schemaVersion` | `SOLUTION_MANIFEST_SCHEMA_MAJOR` | `1` | -| `frontend.hostContract` | `SOLUTION_HOST_CONTRACT_MAJOR` (the `SolutionPageProps` the host injects and the `./Page` default it expects) | `1` | -| `frontend.reactRange` | the host's real React version | unconstrained | -| `frontend.shared` (per package) | the versions the host publishes into the sealed scope | unconstrained | -| `frontend.exposedModule` | must be a `./Name` key | — | - -Ranges use a deliberately small semver subset (`||` alternatives of -space-separated `^ ~ >= > <= < =`, a bare version, or `*`). A range outside the -grammar is **refused**, never approximated: a requirement the host cannot -evaluate is not one it can honour. The host values live in -`services/frontend/code/src/solutions/host-runtime.ts`, which the register route -and the Module-Federation host both read, so the numbers cannot diverge. - -A refused registration is refused **whole**. If the id already had a valid -registration, that registration keeps serving — the last known valid one is never -replaced by a rejected update. The reasons are returned to the registrant (HTTP -409, `{"error":"incompatible_runtime","reasons":[…]}`) and logged for the -operator. If the id has no valid registration to fall back on, its page renders a -plain "temporarily unavailable" panel instead of a 404, and the technical cause -stays in the server log. +## 2. Declared authority + +Only an admitted delivery declaration creates or removes solution presence. +The signed carrier, trust anchor, ownership domains and generation rules are +specified in §7. Runtime credentials confer no authority to change presence. + +The internal `SolutionRegistryService` exposes reads. The gateway reads its +snapshot; the frontend obtains the projection through `GET /solutions/_registry` +using the cluster-internal credential. Registry identity and the immutable target +are host facts, never assertions accepted from a browser or remote component. + +An upstream receives forwarded viewer credentials. The transport therefore checks +resolved addresses at dial time, refusing public, link-local and metadata +addresses. Composition-local addresses and the explicit loopback development +case remain supported. A changed DNS answer cannot bypass the transport check. + +## 3. Runtime compatibility + +The Module-Federation host checks compatibility before activating a remote. +The manifest parser validates the snapshot's shape. Runtime requirements include +`schemaVersion`, `frontend.hostContract`, `frontend.reactRange`, +`frontend.shared` and `frontend.exposedModule`; host versions live in +`services/frontend/code/src/solutions/host-runtime.ts`. A failed check leaves the +remote unavailable and reports the cause in the server log. ## 4. Registration, installation, and entitlement are three different things @@ -171,8 +66,11 @@ They are frequently conflated. In this module they are not the same, and the boundary is deliberate: - **Deployment registration** (this document) is cluster-wide and governs - **UI and API availability**: whether `/s/{id}` renders a remote and whether - `/solutions/{id}/…` proxies. It is per-deployment, not per-organization. + **availability**: whether a solution exists on this deployment at all, and so + whether `/s/{id}` can render a remote for it. It is per-deployment, not + per-organization, and since issue #952 it is no longer sufficient to reach a + solution: `/solutions/{id}/…` additionally requires the caller's organization + to have installed it and a grant to reach the caller. - **Per-org installation** (`InstallationService/InstallSolution`) governs **agent authority only**. Installing composes an agent principal, a `kind='solution'` scope node, a least-privilege standing grant with an @@ -187,153 +85,828 @@ boundary is deliberate: - **Entitlement** (plans and grants, `BillingService`) governs feature availability within the product and is independent of both. -**The limitation, stated plainly:** uninstalling a solution in one organization -revokes that organization's agent authority — the principal and its standing -grant — and does **not** remove the solution's nav entry, page, or gateway route, -for that organization or any other. A registered solution's UI and API surface is -visible to every tenant of the deployment; its ability to *act* on a tenant's -behalf as an agent is what installation confers and uninstall withdraws. Other +**What uninstalling does, stated plainly:** it revokes that organization's agent +authority — the principal and its standing grant — withdraws that organization's +viewers from the projections, and refuses that organization's traffic at the +solution proxy. It does **not** remove the solution's page for that organization, +because `/s/{id}` server-renders from the registry and this origin cannot read a +verified viewer server-side without rotating a refresh token per render; the page +discloses a nav title and a manifest URL the public asset surface already serves, +and every call it makes goes through the gated proxy. Other tenants are unaffected either way, because installation state is per-org. -If a deployment needs per-tenant admission before route or page exposure, that is -an additional gate on top of this model — a tenant check in the solution page and -proxy route — and is not implemented here. - -## 5. Rollout - -The shared cluster-internal token is no longer accepted for either half of -solution registration; there is no permanent bypass. A deployment upgrading to -this contract must declare `SOLUTION_REGISTRATION_SECRETS` for every solution it -expects to register and provision each plaintext to its solution, exactly as -`MODULE_REGISTRATION_SECRETS` is provisioned today. Until it does, registration -fails closed and no solution is served — which is the intended direction of -failure for a surface that decides what executes in the host origin. Adding an -entry afterwards is enough on this side; what remains is for the deployment's -own configuration carrier to deliver the new value to the running process (see -§2, and `codefly-dev/cli#740`). - -Registrations that already exist at upgrade time are carried over, not locked -out. Before this contract the gateway stored a registration's publisher as the -bare solution id (the caller named none); the verified subject is -`solution:`, and a write from a different publisher is refused. Store -migration `130_solution_registrations_verified_publisher` rewrites that -pre-contract default to the verified form, so the solution's own credential -keeps renewing its record after the upgrade. A pre-contract row whose publisher -was self-asserted as anything else was never authenticated; it stays as it is, -and who owns it is an operator's decision — delete the row (or `DELETE` the -registration) and let the credentialed publisher register it afresh. - -**That recovery has a cost, since §6.** Deleting the row discards the solution's -runtime-boundary seed, so the registration that replaces it draws a new one and -every run still executing under a boundary derived from the old seed becomes -unreachable from any page. Deregistering (`DELETE`) does not: it leaves a -tombstone, which keeps the seed, so a reactivation keeps naming the runs it -already admitted. Prefer the tombstone whenever the solution may have work in -flight, and treat deleting the row as what it is — a reset that orphans running -work, not a reassignment. - -## 6. The registration carries the solution's runtime boundary - -A runtime task is reachable only under the boundary of the Work Context that -admitted it: the context's `task_id`. A solution's page therefore needs a -boundary that outlives one context — when a cached context renews, or a call -with another scope set mints its own, the next request carries a fresh -`task_id` and the run the page admitted is gone from view while it keeps -executing. - -So the registration record carries a **seed**. -`solution_registrations.runtime_boundary` is an opaque id **the host assigns -when the record is created**, and the boundary a Work Context is sealed under is -derived from it **per organization** — a UUIDv5 of the seed and the org id -(`business.SolutionRuntimeBoundary`). The seed itself never leaves this host. - -**Per organization, because a run is filed under (tenant, boundary).** One -per-solution value would make every tenant of a solution share one, and an -organization administrator who can read a single execution would then hold the -boundary every *other* tenant's runs are filed under. Deriving puts a different -boundary in each tenant without a second table to keep consistent with the -registration it belongs to. - -**It is assigned, never named.** The column defaults to `gen_random_uuid()` on -insert, the registry's upsert omits it from its update list, and no request -field anywhere reaches it. A UNIQUE constraint keeps two registrations from -sharing a seed. It is stable across both halves, a lease renewal, a replaced -half, and a tombstone and reactivation — the record is the same solution under -the same publisher, so the runs it already admitted stay the ones it can read. - -**Why it could not be the solution's to choose.** If a solution named its -boundary, solution B could mint for A's and read, answer and recover A's runs -for the same viewer: exactly the cross-boundary access the registration -contract exists to prevent. - -**And why no *other* caller may name one either.** A boundary is not a secret a -consumer keeps. A consuming module that serves durable runs reports, on a run it -lets a person read, the Work Context task that run was admitted under — so one -read any caller is entitled to hands them a value that is now stable for the -life of the registration — and a solution's passthrough forwards the viewer's bearer to its -own backend, so that caller can mint. Accounts therefore **refuses** any -caller-named `task_id` that is a registered solution's seed, or the boundary -derived from it for the organization the mint names, on the ordinary mint and on -the headless installation mint alike. Tombstoned registrations are included: a -removed solution's runs may still be executing. The refusal does not say which -solution; the caller learns only that the value is not theirs to name. Only the -organization named in the request is checked, because a capability is sealed -with its tenant and a consumer scopes a run by (tenant, boundary), so naming -another tenant's boundary yields a context that reaches nothing. - -**Nobody is told a seed.** No response carries one — not a listing, not a -deregistration, not either half's own registration. A solution has no use for -it: accounts derives and seals the boundary from the credential the solution -already presents, so nothing above accounts reads, sends or stores one. This -deliberately departs from the wording of -[#1015](https://github.com/codefly-dev/module-saas-starter/issues/1015), which -said a solution's boundary should be "readable by the solution (its own) through -the registration". Echoing it widened who could see a value that is now stable -for the life of the registration and bought nothing, so the registration answer -withholds it. - -**How a mint proves which solution is asking.** The solution presents the same -registration credential on -`POST /saas.accounts.v1.WorkContextService/StartTask`. The gateway verifies it -exactly as it does on a registration — alg-locked EdDSA against the published -JWKS, issuer, `solution-registration` audience, expiry — and stamps the id its -`solution` claim names and the publisher its `sub` names, as -`X-Codefly-Solution-Id` and `X-Codefly-Solution-Publisher`: forwarded identity -headers accounts believes only beside the gateway credential and strips from -every other caller. Accounts then checks three things before it derives -anything — the registration exists and is not tombstoned, its publisher of -record **equals the credential's**, and its **backend half is serving** (that is -the half that mints, and the gateway has already stopped routing to one whose -lease lapsed). A caller-supplied `task_id` is **refused**. The credential is -accepted on no other procedure, and one that does not verify is a `401` rather -than a mint under some other boundary. - -**What rotation costs.** The seed is the only input to the derivation, so there -is no per-organization rotation: replacing it moves every organization's -boundary at once, and every run still executing under an old one becomes -unreachable from a page. Three things replace it — deleting the row (§5), the -down migration, and nothing else. A tombstone and reactivation deliberately do -not. Plan a rotation as an outage for in-flight work, not a key roll. - -**What this migration does not break.** Runs admitted *before* it were reachable -only under their own admitting context, which lived at most 900 s; none of them -had a boundary that outlived it. So nothing that was reachable becomes -unreachable here. - -Two limits worth stating. The boundary is **per solution and organization, not -per viewer**: the person is the capability's owner, and separating two people's -runs under one boundary stays the consuming module's own owner check. A sealed -solution claim that the consumer scoped by would remove that caveat, and -narrowing the read that exposes a boundary would remove the need for the -collision refusal above; both are tracked on the consuming module's own tracker -(its #228 and #227 — this repository may not name it, by the naming and -confidentiality rule in the repository-root `AGENTS.md`). And the `jti` is not -burned on a mint (see `services/auth-gateway/AGENTS.md`), so one credential -can seal several mints inside its five-minute life. - -Tracked as -[#1015](https://github.com/codefly-dev/module-saas-starter/issues/1015). The -page half — a solution's passthrough presenting its credential and no longer -naming a `task_id` — belongs to `codefly-dev/solution-runtime-go` and -`codefly-dev/solution-runtime-python` and is not done here, so **no -page-admitted run keeps its boundary across a renewal yet**. +### What the projections answer (issue #949) + +Registration stays deployment-wide, but the **projections** answer per organization +and per viewer. `GET /api/solutions` (the navigation menu) and +`GET /api/solutions/surfaces?client=` (the per-client surface listing) return +only the solutions the caller's organization has installed **and** the caller's +teams were granted. They used to answer the same registered set to everyone, so a +menu was a function of what was *deployed* rather than of what the viewer may +*use*. + +Both are therefore **authenticated**, and answer `401` to an unauthenticated +caller rather than an empty list — `[]` would be indistinguishable from "your +organization installed nothing". The verified organization and viewer come from one +place only: the gateway's `ext_authz`, read through +`GET /solutions/_entitlements`. Nothing in a route handler may derive them — +`lib/auth-session.ts` decodes an access token without verifying it, so an +organization taken from there is one the caller chose. + +The authority answer is `SolutionEntitlementService.ListSolutionEntitlements`, +which reads the installation set and the scope grant + share union in **one** +transaction, so the two can never describe different moments. The join key is the +immutable solution target: `installations.target_id` is the target a +registration record was **declared** under, which the registry projection carries +as `targetId`. + +It used to be the route alias — `installations.solution_identifier` against the +registered manifest `id` — and that was a transfer of consent waiting to happen. +A route alias is deliberately **reusable**: a tombstoned one may be claimed by +another binding. So an organization that installed the binding behind `reports`, +and exposed it to a team, had that installation and that team grant follow the +alias to whatever claimed it next, with no administrator having acted. A rename +is the same defect from the other side: the installation kept the old text, the +organization silently lost access, and the next claimant of the old alias +inherited the installation. + +A **solution target** is one continuous period of one binding's presence on this +host (`solution_targets`, migration 21). It is opened when a present generation +applies for a binding with no live target, closed by that binding's tombstone +generation, and **never reused** — so a replacement is a different identity and +inherits nothing. The alias lives on the target and moves with a generation that +renames it, which is precisely why it is not the identity. + +Three consequences worth stating, because each is a behaviour rather than a +restatement: + +- **Closing a target revokes every active installation of it, in the same + transaction as the tombstone**, and records `installations.revoked_reason` so a + withdrawal is distinguishable from an administrator's uninstall. +- **Presence nothing declared is admissible to nobody.** A registration record + with no declaration carries no target, so no installation can name it and the + proxy refuses it as a verdict (`403`), not an outage. +- **An installation names a target from `ListAvailableSolutions`**, which serves + accepted *applied* state — never the diagnostic binding listing, which also + reports desired generations that were refused. Consenting to a release this + host never admitted would record authority over a presence that does not exist. + +Four distinctions the projections keep: + +- An installed, granted solution whose installation is **unhealthy** stays listed, + marked unavailable. The organization installed it and the viewer was granted it, + so hiding it would send someone looking for a grant that already exists; what it + must not do is route as though it were serving. +- A **deployed but uninstalled** solution is invisible to viewers. +- A newly installed solution reaches **no team** until a grant is written. + Installing writes a standing grant for the *agent principal* only. A grant at an + ancestor node (the organization root) does reach a solution node, because that is + the scope tree's own hierarchical rule and the same union `CheckAccess` resolves + — narrowing tighter here would hide what an authority check permits. +- An unreadable registry is still `503`, and a missing or malformed `client` still + `400`. Neither ever renders as an empty list. + +Backend authorization is unchanged and remains the real boundary: a client holding +a stale menu still has every call denied by the authority it calls. + +### What the proxy admits (issue #952) + +#949 narrowed the projections and deliberately left route and page exposure +deployment-wide. That was a hole rather than a boundary: a viewer in **any** +organization could call the data endpoints of **every** solution the deployment +ran — with a real bearer forwarded to them — by typing the path the menu declined +to show. Available, installed and exposed are three layers and none is inferred +from another. + +So **the solution proxy admits traffic through the same authority the +projections read.** `/solutions/{id}/…` consults +`SolutionEntitlementService.ListSolutionEntitlements` for the identity +`ext_authz` verified, and forwards nothing until that answer admits the +solution the request addressed. Three answers, kept apart: + +| answer | response | +| --- | --- | +| installed and granted | proxied, as before | +| the authority answered and this organization has no installation of it, or no grant reaches this viewer | `403`, `X-Codefly-Entitlement-Refusal: not-entitled` | +| the authority could not be asked (outage, no client wired, a cursor that never terminates) | `503` — never `403`, and nothing forwarded | + +Four properties worth stating, because each is a decision: + +- **The refusal is named.** A `403` meaning "nobody granted your organization + this" is acted on by installing and granting; a `403` from `ext_authz` by + signing in again. The header is what separates them, and it is the same header + the entitlement listing uses for the same reason. +- **An outage is not a verdict.** "I cannot ask" must neither route nor read as a + missing grant, so it is a `503`. A gateway with no authority client wired fails + closed for the same reason: the behaviour this check replaces was *serve every + solution to every organization*, and that must never be the fallback. +- **Health is not admission.** An unhealthy installation is still installed and + still granted; the registry's own `resolve` already answers `503` for a + registration that is not serving. Reading health here would make "nobody + granted you this" indistinguishable from "it is restarting". +- **Impersonation is decided on the impersonated viewer**, the effective subject + accounts authorizes against — not the administrator acting. Otherwise + impersonation would show more than the user can reach, which is the one thing + it exists to check. + +**The public Module-Federation surface is not gated, and cannot be.** +`/solutions/{id}/assets/*` and `/.well-known/*` are fetched by the browser's +module loader with no credential, so there is no viewer to ask about; what they +serve is the solution's own static bytes, which carry no tenant data. That is a +deliberate residual exposure, not a pending gate: gating it would make a remote +impossible to load same-origin. + +**Backend authorization remains the real boundary** either way: a client holding +a stale menu, or reaching a solution through some path not listed here, still has +every call denied by the authority it calls. + +## 5. Database cutover + +Migration `23_delete_runtime_registration` requires declared target ownership on +all registry rows. It discards undeclared rows and previously reported runtime +observations. Delivery must supply the declared state that the host can reconcile; +no row is implicitly adopted into a new target. The migration is destructive and +cannot recover discarded data. Its down migration fails with that explanation. + +## 6. Per-viewer access + +An organization administrator installs the immutable target through +`InstallationService/InstallSolution`, then grants `solution:use` at the +installation's authority-root scope node to the intended people or teams. +Deployment presence supplies neither consent nor a scope grant. No migration +creates those grants implicitly. See §4 for projections and traffic admission. + +## 7. Declared presence + +A `SolutionHostBinding` records which solution delivery intends to run on this +host. The host records desired and applied generations separately, including the +reason an unaccepted generation remains pending. + +### The document, and who owns which field + +`SolutionHostBinding` is defined by Core (`github.com/codefly-dev/core/solutionhost`, +schema `codefly/solution-host-binding/v1`), rendered per solution instance by +`codefly-dev/cli`, delivered by the GitOps bundle, and reconciled here. It is +desired state and nothing else: it carries no observation, no health and no +credential. The split is the point. + +| Fact | Owner | Where it lives | +| --- | --- | --- | +| That a solution should be present at all | the declaration | `solution_host_bindings.applied_*` | +| Which release runs | the declaration | `release`, recorded as `applied_release` | +| Which route alias it answers on | the declaration | `routes[].alias` | +| The workload identity to expect | the declaration | `workload` | +| Resolved upstream and frontend | host reconciliation | `backend_upstream`, `frontend_manifest` | + +The document deliberately carries no upstream address and no manifest. An +address written into a delivery document is a resolution result that was true on +one cluster until something moved; a manifest is what the running build actually +published. Both are observations that the host resolves under declared authority. + +### The registry key is the route alias + +A binding ID identifies one **deployment instance** — the renderer's is +`..`, dotted — and a second instance of the same +solution inherits nothing from the first, its alias included. It is therefore not +the registry key: the key is the binding's route alias, because the alias is what +this host routes on (`/solutions//*`, and the key a remote loads under). + +This host needs **exactly one** route alias on a present generation, and it must +be a single lowercase path segment. Core's alias vocabulary is wider — it admits +dots and slashes — so a binding that declares none, several, or a dotted one is +refused with a reason naming the route rather than having one picked for it. The +renderer commits to exactly one single-segment lowercase alias per binding, so +this refusal is a guard, not a workflow. + +A tombstone declares no route, which is why a binding's row keeps the key it last +applied: it is the only way a removal knows what to withdraw. + +### Prepare, then activate + +Every pass reads the durable inbox, then asks Core about the **whole** desired set — +route-alias uniqueness and "declared once per set" are properties of the set, not +of a document. Only then is anything written, and desired state is recorded for +every delivered binding before any generation is applied, so what delivery wants +and the reason it was refused are visible even when nothing passed. + +A refused document leaves the running generation untouched. The refusal is +attributed to its binding rather than failing the pass, because the common +refusal is a stale render: the renderer derives a generation from the previously +delivered document, so a render from a checkout that cannot see the prior tree +emits generation 1 and is correctly refused as stale. If that held back the whole +set, one bad pipeline run would freeze every solution on the host. A refusal Core +raises that this host cannot attribute to a document withholds the **whole** set — +failing closed on a rule it does not recognise, rather than applying a subset Core +never approved. + +Two replicas converge without applying a generation twice: each apply re-decides +under the binding row's lock, and `DecisionCurrent` — this generation is already +applied — writes nothing. A restart resumes from the recorded applied generation +rather than deriving it again. + +An unreadable inbox is an **error**, never an empty desired set, and an empty +one removes nothing. Removal is a tombstone generation precisely so that a store +this host cannot read can never be reconciled as "withdraw every solution". +Nothing deletes from the inbox either, which is what keeps "delivery stopped +talking" and "delivery said remove it" different facts. + +### Reading the three states apart + +`SolutionRegistryService/ListSolutionHostBindings` (`EXPOSURE_INTERNAL`) answers +with desired, applied and observed state separately. That is the question the +registry snapshot alone cannot answer: a binding whose first generation was +refused has no registration record at all, so it is invisible there, and +"declared but never observed" and "never declared" look identical. + +### The ownership domain, and why the host persists it + +A document declares the **ownership domain** it speaks for: the slice of this +host's binding space that delivery may add to, change and remove within. It is +what lets a module-scoped render express a removal without "remove everything +else" being expressible at all. + +Core requires the host to declare the domains it accepts whenever it knows its +own coordinate, and refuses a document from an unstated one — **first generation +included**. That is not belt-and-braces: the applied record cannot bound a +binding's *first* generation, because there is nothing yet to compare against, so +without the declaration any accepted delivery could claim an unseen binding ID +under a domain of its own choosing and own it from then on. + +The host therefore **persists the domain each generation applied under** +(`solution_host_bindings.applied_domain`) and hands it back to Core on every +pass. Core refuses a later generation for a binding that arrives under a +different domain, which is what stops one delivery taking over a binding another +delivery owns. Two consequences worth stating, because neither is obvious: + +- **It is not re-derivable from configuration.** The host's configured list + answers "a domain this host accepts", not "the domain this binding was claimed + under", and those differ exactly when it matters — a host accepting two. +- **An applied record without one is unusable, and unusable means the whole + set.** Core refuses an applied state with no domain, and a pass judges the + desired set as a whole, so a single domainless record does not degrade one + binding: the reconciler applies its first generation and then refuses + everything on every pass afterwards. The applied group's whole-or-absent CHECK + and a non-empty constraint keep that state out of the table. + +The core-owned half of the applied record is built by `solutionhost.AppliedFrom` +rather than assembled here, so the next field Core makes required is a loud +refusal at the apply instead of a silent omission that surfaces as a frozen +reconciler. + +### Configuration + +Every key is in the `federation` group. + +| Key | Meaning | +| --- | --- | +| `SOLUTION_HOST_COORDINATE` | the coordinate this host answers for, exactly as the operator declared it on the environment the renderer read. Never derived here — an invented coordinate matches nothing delivery wrote, so every document would be refused. It is also the one declaration that turns the surface **on**: empty means no reconciler and no delivery endpoint, and no new declared presence can be reconciled. | +| `SOLUTION_HOST_OWNERSHIP_DOMAINS` | the ownership domains this host accepts delivery from. Required with the coordinate and refusing to boot when empty, for the same reason the coordinate is: it is the only thing bounding a binding's first generation. | +| *(no setting)* | WHERE the trust root and verification policy are read from is **not configurable**: it is a constant, `infra.SolutionHostTrustAnchorPath`. There is no environment value, no flag and no overridable default. Workspace environment is delivered by the **composition**, so an overridable path would let a composition point this verifier at a policy and root it wrote itself, sign its own presence documents, and pass every downstream check — against an anchor it chose. A deployment needing a different path changes the pod spec that mounts it, which the platform renders and the composition cannot supply. That document also carries the signer-to-domain mapping, which **replaced** an env var: a bare certificate SAN as the key is accepted from any issuer and the same workflow path exists in every fork, and the env var let whoever set it widen what an accepted signer speaks for without touching the independently-delivered policy. A signer granted a domain `SOLUTION_HOST_OWNERSHIP_DOMAINS` does not accept is refused **by name**. | +| `SOLUTION_HOST_TRUST_POLICY` | how a delivered carrier's bundle is checked. `keyless` is the only value. Required with the coordinate, with **no default** — every default is wrong somewhere. The trust anchor's **absence** is checked first and refuses the boot naming the anchor, so an unmounted anchor is never reported as a missing setting. | +| `SOLUTION_HOST_BINDING_INTERVAL` | optional; how often the inbox is re-read. Empty uses 30s. | + +Declaring the coordinate without the ownership domains refuses to boot: this +host would be unable to refuse a document claiming a binding it has never seen. +So does declaring it with no trust anchor mounted at +`infra.SolutionHostTrustAnchorPath`, and so does declaring it where no +TokenReview can be performed — a delivered carrier is authorised by TokenReview, +so a host that cannot reach its API server has no input at all and starting +would leave it polling an inbox nothing can write to. + +**There is no mount setting.** It was `SOLUTION_HOST_BINDINGS_DIR`, a projected +ConfigMap volume, and both it and the directory reader are deleted. A mount is +the CURRENT desired set and nothing more, so a document that arrived, was +recorded as desired, failed to apply and then disappeared from the mount was +never retried — the host had recorded that delivery wanted something and had no +way to want it again — and the mount dropped the carrier, so a restore could not +re-verify what it had accepted. While the setting existed the durable inbox was +dead in every configuration: unset meant no reconciler at all, and set meant the +source was the directory, so the table the delivery endpoint writes was read by +nothing. + +### The authority half, and the one shape in which it is active + +Delivery has two kinds and the host serves both on their own paths: +`POST /platform/_delivery/presence` and `POST /platform/_delivery/authority`. +Each is verified with the reader for **its** kind, and the kind in the path is +checked against the schema inside the attested bytes — which is a check rather +than a convention, because the schema is part of the canonical encoding the +signature covers, so a document's type is attested rather than asserted by the +carrier. A document sent to the other kind's path is **400**: the bytes are +wrong, not the signer, and a 403 would send an operator to look at a signing +identity that signed the document perfectly well. + +An authority row is keyed by its **own** authority id, never by the presence +binding it is granted over. One binding may be granted authority under successive +authority ids, and the withdrawal fold is keyed on the binding precisely so that +re-signing under a new id cannot reinstate a withdrawal — which only works if the +inbox keeps the id each document was delivered as. + +**Neither half activates alone.** Authority is active only as a matched +(authority, presence, build) tuple, which is what makes "approved for one exact +build" a property of the system rather than of a field: an authority document for +a build the host is not running activates nothing, and a presence document with +no authority behind it activates nothing. The build is asked **about** rather +than read out of either document — reading it out of the document that approves +it would make the question answer itself. + +The **ceiling** is Core's `Envelope`: who may hold which binding, and which +builds are approved, at one revision. It is read from the same platform-owned +trust anchor as the trust root (`authority_envelope.json`), and never out of a +delivered document — "an envelope a document carried would be a document +declaring its own ceiling", and containment is tested against the envelope's own +grants, so an envelope assembled from the delivery tree answers itself. Absence +and damage are different answers: no envelope delivered is a complete deployment +that answers no authority question, while an envelope that is delivered and +cannot be read refuses the boot, because reading a damaged ceiling as "no +ceiling" turns a corrupted file into a silently narrower system and reading it +permissively turns it into a wider one. + +**This host applies authority to nothing**, and that is stated rather than +defaulted. Core requires either the applied authority record or an explicit +marker that none exists, because "nothing applied" is the most permissive input +the call takes; this host holds no such record, so the marker is the truth. The +replay protection the fold would otherwise give comes from the inbox instead — +the desired set is the **newest** generation per document, so a genuinely signed +older generation re-delivered never becomes the answer. The one part the inbox +cannot do by itself is refuse an authority withdrawn under one id and re-signed +under another, so the host reads **every** delivered authority document over the +binding and refuses when a tombstone is among them. Nothing deletes from the +inbox, so a withdrawal stays visible forever. + +Two live authority documents over one binding are refused by name rather than +resolved: whichever one a host picked would be a choice nobody reviewed. + +### What the host admits, and why it is now a signed carrier + +Core `cd443989` removed every path from unattested bytes to a host judgement. +`Host.Admit` takes `*solutionhost.Delivered`, whose fields are unexported and +whose only constructor is `VerifyDelivered(carrier, BundleVerifier)`. So "this +document was attested" is held by the compiler rather than by a naming +convention, and a host that forgot to verify cannot compile rather than +discovering it in production. + +Core `67ee7220` closed the gap that left: a `Delivered` used to hand out the +parsed document it would itself admit, so a caller that adjusted a field changed +what was admitted while the attestation still covered the original bytes. It now +stores the attested payload and re-derives per read. + +The host side of that: + +- **The mount carries signed carriers**, `{schema, document, bundle}`, not bare + documents. A bare document is refused — it is not a carrier — and that refusal + is pinned by a test. +- **The signing input is the canonical JSON** (`CanonicalBytes()`), not the YAML + `Marshal` writes for a delivery repository. Core refuses a payload that is not + the canonical encoding of the document it decodes to *even when the attestation + over those bytes is genuine*, because a signer and a host that disagree about + which bytes represent the document disagree about what was approved. +- **The verifier returns a signer identity, not a yes.** The host maps that + identity to the domains it may speak for, and Core refuses a document whose + asserted domain its attested signer may not. This closes the hole that a + document states its own ownership domain: without the policy, any signer the + host accepted at all could deliver under any domain it accepted and take over + bindings in it. +- **A bundle must be present and be a JSON object.** Core looks inside no + bundle, but "there is one" is checkable, and absent, empty or `null` is + refused as unsigned on the verify path — not only on the parse path, as it + was before `67ee7220`. A delivery that writes the field as `null` is refused + rather than admitted as a document nobody signed. +- **What the host stores is derived from the attested bytes**, every time, and + never from a document the reconcile pass has been holding. The generation in a + row and the digest beside it are therefore over the bytes the signature + covered, which is the property that makes the digest worth storing at all. A + carrier whose attested payload will not re-read is refused like any other, + rather than admitted from a value read earlier. + +#### The one trust policy + +| Policy | What it checks | Where it is usable | +| --- | --- | --- | +| `keyless` | a Sigstore bundle against a trust root and an identity allowlist — repository, workflow path, ref pattern, issuer. **Never a key.** | everywhere, including a laptop. **The trust root is not yet pushed to the config plane, so it refuses at boot**, see below. | + +There is no second row. `local` was deleted — §8 argues why at length — and this +table listed it until the code had already stopped accepting it. A configuration +table is the one place a reader goes to find out what a value may be, so a +deleted value left in it is not a stale sentence but an instruction to write a +host that refuses to boot. + +**`keyless` refuses at boot, and that is deliberate.** Keyless verification needs +a trust root and an identity allowlist provisioned **independently of both +delivery writers** — a deployer who can edit the allowlist does not need to forge +a signature, because it can replace the verifier. Nothing on this deployment +provisions either yet. A verifier written against them could not be exercised +against a single real bundle before shipping, in the one service that owns +identity, so it is not written: the refusal names what is missing. It refuses at +**boot rather than per document**, because "this host cannot perform its +configured policy" and "delivery is shipping something bad" are different facts +and only one of them is the operator's to fix. + +A refusal recorded against a binding is deliberately **coarse** — not a carrier, +a bundle that did not verify, and a verified payload Core will not parse all read +the same in the row. Which of the three it is tells an attacker which half of the +door it got past; the full reason goes to the log. + +The host reads a **directory of files** and knows nothing about how they got +there: a projected ConfigMap volume in a deployment, a plain directory under +`codefly run` locally. Kubernetes' atomic writer is honoured — every dot-prefixed +path element (`..data`, `..2026_…`) is skipped, so one document is never read +twice under two names, which Core would refuse as a binding declared twice. + +## 7b. The host's own control paths, and which part a consumer may hardcode + +Three repositories independently invented the string `/platform/_credential` +from a design note, and nothing failed at build time. This section exists so the +next one reads a contract instead of guessing, and so the hardcoding question has +a stated answer rather than a convention nobody wrote down. + +### The rule: a path is a contract, an origin is a resolution result + +"Never hardcode what the system resolves" governs **resolution results** — +addresses, ports, credentials, injected environment: things that are true on one +cluster until something moves. A URL **path** on a published API is not one of +those. It is a contract, like an RPC method name or `/v1/users`, and making it +discoverable would only move the hardcode to the discovery URL. + +So a consumer: + +- **resolves the origin.** The gateway address comes from whatever already hands + the workload its environment — Codefly service discovery for a composed + module, the injected configuration for an independently deployed runtime. A + runtime that can already call the host has it. +- **may hardcode the path**, because it is published here and pinned by test. + What it must not do is *invent* one. + +`GET /solutions/_registry` and `GET /solutions/_entitlements` are gateway code constants published in this +document, and every registrant hardcodes those paths today. + +### `/platform/*` is the host's own control namespace, and it is reserved + +| Path | Status | What it is | +| --- | --- | --- | +| `POST /platform/_credential` | **Settled, NOT YET LIVE** | the module/solution credential mint. On the **gateway**, which forwards `Authorization` verbatim; accounts performs the `TokenReview` on the pod's own token. | +| `POST /platform/_delivery/presence` | **LIVE** | a signed presence document, `{schema, document, bundle}` | +| `POST /platform/_delivery/authority` | **LIVE** | a signed authority document, same carrier | + +**The two delivery paths are now served by accounts**, which performs the +`TokenReview` on the carrier's own token. The credential mint remains **NOT YET +LIVE** and is brokered by the gateway when it lands; a consumer calling it today +gets a 404 on every deployment, not just one, which is the one mercy in the +situation. Keep an override until this table says live for it, and do not treat +a 404 as "my path is wrong". + +Delivery answers on this taxonomy, and it is organised by **what a retry would +change** rather than by severity: + +| code | meaning | retry? | +| --- | --- | --- | +| `202` | verified, new, and now durable desired state — applied or not | no, it worked | +| `200` | an exact replay of a `(document, generation, content hash)` already held | no, it worked | +| `400` | the carrier does not parse, or the payload is not the canonical encoding of the document it decodes to | terminal | +| `401` | the carrier's token was REVIEWED and refused | terminal — the token is kubelet-projected and re-read per request, so a refusal is a configuration fault and a retry asks the same question | +| `403` | authenticated, but not the authorised writer for this kind; or no accepted signer; or an accepted signer not granted the asserted ownership domain | terminal | +| `409` | this generation is already held with DIFFERENT bytes | terminal — publish never rewrites a delivered generation, so this is a hand-edited delivery tree | +| `422` | the document parses and `Validate` refuses it | terminal | +| `503` | the host could not REACH the API server or its trust root | **retryable** — nothing was verified, so nothing may be concluded | + +**The receipt's field names are part of the contract**, and they are +`camelCase`: + +```json +{ + "disposition": "accepted", + "documentId": "acme.test.example-solution", + "generation": 3, + "contentHash": "sha256:…", + "signer": "" +} +``` + +`disposition` is `accepted` on a `202` and `replayed` on a `200` — the same +distinction the status codes carry, repeated in the body because a pipeline that +reads only the body should not have to infer it. A refusal carries `error` +instead. `signer` is the identity the BUNDLE attested, not the service account +that handed the carrier over: those are different parties, and conflating them in +a receipt would make an authorised carrier look like the author of whatever it +delivered. + +These names are pinned by +`TestNewGenerationIs202AndAnExactReplayIs200` in `pkg/adapters`, which declares +them independently of the handler's own struct — a cross-repo contract that only +one side spells out is a contract that side can rename silently. (The first draft +of that test guessed `snake_case` and failed, which is how the names came to be +written down here at all.) + +The `401`/`503` split is the one that matters and the one an earlier draft got +wrong by grouping on HTTP class. "The review ran and refused" and "the review +could not run" are different facts with opposite retry answers, and they come +from different branches of the same call. A `404` stays retryable too: a +reachable host without the route is a version behind the Job, which is ordinary +during a roll. + +`/platform/` is **reserved for the host** and no delivered binding may claim it. +Route aliases live under `/solutions//` and composed module routes under +`/v1//`, so nothing can shadow it today; it is reserved explicitly so that +stays true. An unknown `/platform/*` path must answer **404**, never fall through +to another handler — the same trap the adversarial review names for the +`/solutions/_…` segments, where deleting a dispatch case lets a request reach +generic solution routing and answer `502`. + +### The delivery carrier contract, settled across three repositories + +Published here because three repositories implement against it — the renderer +that signs and posts, the infrastructure that provisions the identities, and this +host that verifies — and because every item below was something at least one of +them had wrong at some point. + +**The carrier is three keys**, exactly the renderer's `MarshalSigned` output: +`{"schema":"codefly/solution-host-signed/v1","document":,"bundle":}`. +Parse it with core's `ParseSigned`, which requires `schema` — not by unmarshalling +two of the three, which is how a hand-built carrier claiming any schema used to +reach the bundle verifier before core's `Signed.validate()` closed it. + +**Carrier authorisation is the SA and the namespace, and the two halves differ:** + +| half | ServiceAccount | namespace | +| --- | --- | --- | +| authority | `delivery` | `platform-authority` — a fixed pair | +| presence | `delivery` | **the namespace the document's own workloads declare** | + +The presence namespace is deliberately not a constant. A presence Job runs one +per module tree in that module's own namespace, so a fixed value would refuse +every genuine carrier. It is read out of each workload's `identity.spiffe_id` +(`spiffe:///ns//sa/`), which makes it a property +of the **signed document** rather than of host configuration a deployer can edit +— the same reason the ownership domain lives inside the canonical bytes. A +presence document whose workloads declare two namespaces is refused by name: the +renderer uses one namespace per render and cannot emit such a document, so one +that exists was hand-built. + +A presence generation that declares **no** workload declares no namespace, and +there are two of those: a tombstone, which declares absence and carries nothing, +and a present generation that renders only a frontend surface. Such a carrier is +authorised against the namespace this host **last applied** for that binding — +its own recorded state, from a generation it already verified and admitted, +rather than anything the arriving document asserts. Refusing the empty case +outright made **removal through this endpoint impossible**: removal is a +generation, so the one document delivery must be able to hand over to withdraw a +binding was the one document the check could never authorise. It is still refused +when neither answers — a first generation for a binding nothing has applied, +declaring no workload — because then there is nothing to authorise it against at +all. + +Authority is the **only** half with a hardcoded pair, which is why it is written +down rather than derived: get it wrong and the `TokenReview` *succeeds*, the +identity is *genuine*, and the host refuses the real carrier for a name — which +reads as an attack rather than a typo. + +**The projected token's audience is `accounts`, exactly that literal.** It is +published here because the renderer renders whatever this document publishes, and +an audience the two sides guess at separately is a `TokenReview` that succeeds for +a token minted for something else. It is the same audience the credential mint +reviews against, deliberately: accounts is the service performing the review in +both cases, and a second audience would be another projected token to mis-mount. + +It is a constant in this host rather than a setting, and that is the point. A +configurable audience is another composition-supplied input to a trust decision — +whoever could set it could have a token minted for a service of their choosing +reviewed here as if it had been minted for this one. + +**The perimeter is not this check.** A manifest claiming a namespace other than +the one its delivery tree declares is refused by the delivery controller's own +destination list, at apply. The host's SA-and-namespace check is the second +layer. Both are worth having; neither is described as the only one. + +**Verification is OFFLINE, and there is no egress to a transparency log.** The +delivered namespaces are default-deny egress, so an online Rekor or Fulcio lookup +would **hang rather than fail fast** — the worst failure shape available, because +it surfaces as a staging timeout instead of a permission error in a test. A +bundle verifies offline because it carries its own evidence: the renderer signs +with `sigstore-go` and the bundle carries **both** the signed entry timestamp +(`inclusionPromise`) and the inclusion proof with its signed checkpoint +(`inclusionProof`), media type +`application/vnd.dev.sigstore.bundle.v0.3+json`. The host therefore verifies the +SET and the inclusion proof against the Rekor key in a **mirrored trusted root** +delivered from the config plane, with the transparency-log and observer-timestamp +thresholds set and online verification deliberately off. + +The trusted root must carry the public-good Fulcio chain, the Rekor key and the +CT log keys, because the signer is the public-good instance with no TSA — so the +observer timestamp is the SET's integrated time, and a Rekor v2 log would +additionally need its base URL on the log entry in that root. + +**A bundle with no transparency evidence is refused BY NAME**, distinct from a +signature failure. The two have opposite causes — a signer configured without +transparency logging is a misconfiguration to fix, and a bad signature is an +attack to investigate — so collapsing them makes the first read as the second. +Both this host and the renderer refuse it by name, so a refusal means the same +thing wherever it is seen. + +**Check it BEFORE the verifier runs, on the decoded bundle, not by classifying +the verifier's error.** This ordering is the whole of it, and the obvious +implementation is the wrong one: the verifier reports a bundle with no log entry +as *"not enough verified log entries from transparency log: 0 < 1"* — **the same +words** it reports for a bundle whose log key the trusted root does not hold. +One is a signer that never logged; the other is a root that cannot check the log +it did. A classifier reading that string cannot tell them apart, so the test is +`HasInclusionPromise() || HasInclusionProof()` on the decoded bundle, ahead of +verification. + +**An RFC 3161 signed timestamp does not stand in for transparency evidence.** It +attests *when* something was signed, not *that it was logged*, and a bundle +carrying one still has nothing a mirrored root can check an inclusion against. +The refusal must hold with a timestamp present, which is the case a "has it got +any evidence at all?" check quietly admits. + +**The authenticating container is derived, not declared.** The renderer names the +container matching the service, or the sole container, and refuses at render a +multi-container workload where neither holds — rather than guessing. +`non_authenticating` is always a non-nil list of every other container, init +containers included, so "there are none" and "nobody said" stay distinguishable. + +What neither the renderer nor this host can check is that the projected +`accounts`-audience token is mounted by **exactly** the authenticating container: +a pod-bound token is the pod's. That is closed by the deployment's +execution-admission policy, not by a document field, and until it is in place this +host's container designation is a **record** rather than attestation — stated that +way deliberately, because "the host knows which container may authenticate" would +be a claim the mechanism does not yet support. + +### Why the mint is brokered by the gateway rather than called on accounts + +A solution runtime is an independently deployed workload, not a composed module +on the internal tier, so letting it reach accounts directly means widening +accounts' internal listener to arbitrary workloads — worse than one more +brokered route on an edge every solution already reaches. + +Brokering stays safe because the carrier check is a `TokenReview` of the **pod's +own** token: the gateway forwards `Authorization` verbatim, never substitutes its +own identity, and is a transport hop rather than an audience. A pod token for +audience `accounts` only ever authenticates that pod. The gateway→accounts +brokering pattern is the same one `GET /solutions/_entitlements` already uses. + +## 8. Local runs: one attestation path, not a local mode + +Binding a credential to an approved execution means reading what the workload +actually runs: `TokenReview` on its projected service-account token, then the pod +by the name that review returns, then the application container's `imageID`. That +is Kubernetes. `codefly run` on a laptop has none of it, and the adversarial +review is right that the delivery API does not preserve the old laptop behaviour — +a host that reads pods is not a Kubernetes-free host any more. + +Two paths were allowed: an equally authoritative local attestation the host +verifies under a `local` trust policy, or Kubernetes locally. **This host takes +Kubernetes locally, and ships no local attestation path at all** — and as of the +keyless verifier landing, that is now true of the CODE and not only of this +paragraph. The `local` trust policy is deleted. + +It is worth saying why it existed and why its deletion is a fix rather than a +tightening. `local` performed no cryptography and attested every carrier as the +identity `local`, gated on the host's own coordinate beginning `local/`. Both +adversarial reviews named the same concrete failure: the gate was a string +comparison against an **operator-declared** value, so a deployed host configured +with a local coordinate admitted any file any mount writer dropped — and because +the coordinate is self-asserted on both sides, the misconfiguration was not +self-defeating. The rest of this section had already argued at length +that there should be no local trust *mechanism*; §7 shipped one anyway, because +keyless was unavailable and a laptop had to run. Keyless exists now, so the argument and the code agree: +one verifier, one policy shape, two sets of listed identities. + +### Why the local path is not built, rather than built carefully + +A `local` trust policy can be made *argably* safe. Key it to a reserved coordinate +namespace, refuse it whenever the host's own coordinate is not in that namespace, +and the attack needs a deployed host configured with a local coordinate — which +would also make it refuse every real presence document and serve nothing. The +misconfiguration is self-defeating. + +That argument is sound and it is still the wrong thing to rely on, for three +reasons that compound: + +1. **It is a property of configuration, not of the artifact.** "A local path must + not be usable against a deployed host" is a promise a check keeps, and a check + can be wrong. With no local path, the promise is kept by absence. Under the + maximum-security rule the structural answer wins and the resulting prerequisite + is a tool to build, never a reason to soften. +2. **It is a second implementation of a trust decision**, which is the mistake the + Work Context single-implementation rule exists to stop, in a different place. A + wire contract has one implementation in the repo that owns the type; an + attestation contract is no different, and the second copy is what drifts. +3. **It would put signing keys back on laptops** at exactly the moment delivery + went keyless to eliminate key custody. Whatever signs a local attestation is + material a host is configured to trust, held on every developer's machine. That + is a new custody problem adopted to avoid a dependency. + +There is also a gain, not only a cost. Under a local attestation the laptop +exercises a path production never runs, so what a developer verifies is a +simulation of the mechanism. Under Kubernetes locally, `codefly run` drives the +real `TokenReview`, the real pod read, the real `imageID` comparison and the real +incarnation record. The local run becomes a test of the thing that ships. + +The cost is plain and is not hidden here: every developer needs a local cluster, +and `codefly run` has to provision or attach one and run each service as a Pod +with a projected service-account token for audience `accounts`. That is work for +the tooling, which is where the rule puts it. + +### The delivery signature has the same gap, and it closes without a local mode + +The options as posed cover the mint. They do not cover the other half: delivery +documents are signed **keyless, over the CI provider's OIDC identity**, and a +laptop has no such identity either. A local run therefore cannot produce a +delivery signature any more than it can produce a pod. + +It does not need a second mechanism. Keyless signing is not limited to a CI +workflow identity — a person has an OIDC identity too. So a local run signs with +the **developer's own identity**, through the same Sigstore flow, verified by the +same verifier against the same kind of policy. **What differs between local and +deployed is the identity allowlist, which is data, not code.** + +That is what makes the boundary structural rather than promised. A deployed host's +allowlist names the reviewed-change workflow identity and nothing else, so a +locally signed document is refused there by the **ordinary** check that refuses any +unlisted identity — not by a mode flag, not by a coordinate comparison, and not by +a code path that exists only to be disabled. There is no `local` trust policy +because there is no local trust *mechanism*: one verifier, one policy shape, two +sets of listed identities. + +A deployed host that somehow listed a developer identity would be misconfigured in +exactly the way an allowlist is designed to make visible — it is a reviewable line +in the policy, which is the property the signing decision was taken for. + +### What a local run must therefore supply + +The host's requirements do not change for local; what changes is who provides +them. A local run needs: a presence document whose `host.coordinate` is the +coordinate the local host is configured with; an `execution.image.manifest_digest` +read from the **locally built** image rather than a registry, which moves on every +build and is fine because the generation moves with it; workloads running as Pods +with projected service-account tokens for audience `accounts`; and the documents +signed with the developer's keyless identity and POSTed to the same +`/platform/_delivery/*` endpoints. The local host is configured with its local +coordinate and an allowlist containing that developer identity. + +Nothing on the host is conditional on any of it. That is the point. + +### The three fields a local run looks unable to fill, and why it can + +The renderer's objection to this decision was precise and worth recording with its +answer, because it is the objection anyone will raise again: a present generation +must declare at least one rendered artifact with a SHA-256 digest, and v2 adds a +required OCI manifest digest and a SPIFFE ID — so a laptop appears to have to +**invent digests**, which it must not. + +It does not, once the local run uses pods: + +- **`artifacts[].digest`** — to run pods the renderer must produce Kubernetes + manifests, and those are real bytes with a real digest. That is a rendered + artifact in the honest sense; it lives in a local tree rather than a delivery + repository, which the document never contradicts because there is deliberately + no artifact URI. +- **`execution.image.manifest_digest`** — the locally built image's manifest + digest. Real, and it moves on every rebuild, which is correct rather than + awkward: the generation moves with it, so each rebuild is an ordinary apply. +- **`execution.identity.spiffe_id`** — the one that looks impossible and is not. + Core validates this field for **well-formedness only**: the parsed scheme must + be `spiffe` and nothing more. It does not require an issuer to exist, a trust + bundle to be present, or an SVID to be obtainable. So a local run populates it + with the service-account-derived identity, which *is* the identity the pod runs + as, and whether anything verifies it remains the conditional-mTLS rule — require + and verify a peer certificate only where a trust bundle is projected. No local + SPIRE, no invention, and no schema exception. + +So presence v2 **can** describe a local run honestly. What it cannot describe +honestly is a run of bare *processes* — which is an argument for running pods +locally, not an argument for a second trust model. + +### Two rules that do not bend locally + +**Absence is never removal, under any policy.** A local run makes this tempting in +a way a deployment does not: stopping `codefly run` feels like removing what it was +running. It is not. Removal is a tombstone generation, delivered, locally included. +A reconciler that inverted this under a local flag would hold two opposite answers +to "the desired set is empty" selected by configuration, and one misdetection would +withdraw every solution on a deployed host silently, with the audit trail recording +that it was asked to. `TestSolutionHostBinding_AnEmptyMountRemovesNothing` and its +unreadable-mount sibling exist for that reason. + +**The envelope stays an independent upper bound.** Locally there is no reviewed +envelope, and the tempting shortcut is to let the delivered authority *be* the +ceiling. That collapses two of the three terms in `binding ∩ envelope ∩ +installation`, so a developer would exercise a different authorisation shape than +production computes. A local envelope file in the host's `local` configuration +profile — same schema as the reviewed one, authored by the developer — keeps the +structure identical at the cost of one more file. + +### If the local-cluster requirement is judged too expensive + +The honest alternative is **not** a second trust model. It is **no capability +surface locally**: a local run gets presence and routing so a developer sees their +UI, and module credentials are simply not minted. This host already has that shape +— an absent `MODULE_IDENTITY_SECRETS` denies every module identity exchange, and +the fake-auth fixture is the established local-authority mechanism. + +That is a *reduction* in local capability, which is honest and leaves one trust +path, rather than an *addition* of a parallel one. "Lighter laptop" against "two +trust models" is a false choice while "lighter laptop, less local capability" +exists, and it is the option to weigh against this section rather than the local +attestation. diff --git a/module/configurations/local/federation.env b/module/configurations/local/federation.env index 727c9075d..1ca600335 100644 --- a/module/configurations/local/federation.env +++ b/module/configurations/local/federation.env @@ -26,3 +26,94 @@ MODULE_IDENTITY_SECRETS= # a solution mounts against a host that is already serving, so adding or removing # an entry here takes effect without restarting accounts. SOLUTION_REGISTRATION_SECRETS= + +# Declared solution presence (issue #952): the coordinate this host answers for. +# +# It is the ONE declaration that turns the surface on. Empty leaves the whole of +# it off — no reconciler and no delivery endpoint — which is the default while +# the runtimes still register themselves: nothing on this host is declared, and +# every solution is present because it heartbeats. +# +# There is NO mount setting. It was SOLUTION_HOST_BINDINGS_DIR, a projected +# ConfigMap volume, and both it and the directory reader are deleted. A document +# reaches this host exactly one way: delivery POSTs a signed carrier to +# `/platform/_delivery/{presence,authority}`, the host verifies it on receipt and +# persists it, and the reconciler reads its desired set from that durable inbox. +# A mount could not be the desired set — it is the CURRENT one, so a document +# that arrived, was recorded as desired, failed to apply and then disappeared was +# never retried, and the mount dropped the carrier so a restore could not +# re-verify what it had accepted. +# +# Declaring the coordinate where no TokenReview can be performed refuses to +# boot: a delivered carrier is authorised by TokenReview, so a host that cannot +# reach its API server has no input at all. +# +# The coordinate is the exact string an operator declared on the environment the +# renderer read (`environments[].host.coordinate`). It is never derived here: a +# coordinate this host invented would match nothing delivery ever wrote, so every +# document would be refused as targeting a different host. +SOLUTION_HOST_COORDINATE= + +# The ownership domains this host accepts delivery from, comma-separated. +# Required with the coordinate, and empty refuses to boot. +# +# Core refuses a document whose ownership domain this host has not stated, and +# the reason is that the applied record cannot bound a binding's FIRST +# generation: there is nothing yet to compare a domain against, so without the +# declaration any delivery could claim an unseen binding ID under a domain of +# its own choosing and own it from then on. It is the host's to declare because +# Core cannot know which delivery is entitled to a name it has never seen. +SOLUTION_HOST_OWNERSHIP_DOMAINS= + +# WHERE the trust anchor is read from is NOT a setting, and there is deliberately +# no key here for it. It is the constant `infra.SolutionHostTrustAnchorPath`, and +# the two documents on it are `trusted_root.json` (the mirrored Fulcio/Rekor +# root) and `verification_policy.json` (the identity allowlist, and which +# ownership domains each accepted signer may deliver under). +# +# This WAS a key, SOLUTION_HOST_TRUST_MOUNT, and that was a hole rather than a +# convenience. Workspace environment is delivered by the COMPOSITION, so a +# composition could point the verifier at a policy and a root it wrote itself, +# sign its own presence documents with a key that policy listed, and pass every +# downstream check — because every check was against an anchor it chose. That is +# not a check that can be hardened: the thing under attack is where the check's +# own inputs come from, so it has to hold by construction. A deployment needing a +# different path changes the pod spec that mounts it, which the platform renders +# and the composition cannot supply. +# +# The anchor's ABSENCE refuses the boot, naming the anchor. A host configured to +# verify and unable to must not start and claim it is verifying: refusing per +# document instead makes "this host has no trust root" and "delivery is shipping +# something bad" the same observable. + +# How a delivered carrier's bundle is checked. The only value is `keyless`. +# Required with the coordinate, with NO DEFAULT, because a host must say how it +# checks a carrier rather than inherit one. +# +# `keyless` verifies a Sigstore bundle against the mirrored trust root and the +# identity allowlist on the trust anchor, never a key. Verification is +# OFFLINE: the bundle carries its own transparency evidence, and the delivered +# namespaces are default-deny egress, so an online Rekor or Fulcio lookup would +# HANG rather than fail fast. +# +# There used to be a second value, `local`, which performed NO cryptography and +# attested every carrier as the identity `local`, gated on this host's coordinate +# beginning `local/`. It is DELETED. The gate was a string comparison against an +# operator-declared value, so a deployed host configured with a local coordinate +# admitted any file any mount writer dropped — and because the coordinate is +# self-asserted on both sides, the misconfiguration was not self-defeating. +# +# A laptop runs the SAME verifier: a local trust root and an allowlist naming the +# developer's own signing identity, on the anchor. The boundary between local and +# deployed is the identity allowlist, which is data rather than code — so a +# deployed host refuses a locally signed document through the ordinary check that +# refuses any unlisted identity, not through a mode flag. +# +# A configuration still carrying `local` fails to boot by name rather than +# falling through to `keyless`. +SOLUTION_HOST_TRUST_POLICY= + +# How often the host re-reads the delivery inbox. Delivery POSTs a carrier at its +# own cadence and the reconciler is not woken by it, so the pass is a poll and +# this is the convergence bound. Empty uses the built-in default (30s). +SOLUTION_HOST_BINDING_INTERVAL= diff --git a/module/configurations/local/module-capabilities.env b/module/configurations/local/module-capabilities.env index 165ed6bc9..8842e2bcf 100644 --- a/module/configurations/local/module-capabilities.env +++ b/module/configurations/local/module-capabilities.env @@ -4,6 +4,9 @@ # # {"documents": {"queues": ["datasource"], "namespaces": ["document"], # "resources": ["documents"], +# "workload": {"service_account": "documents", +# "namespace": "", +# "container": "app"}, # "cross_tenant": true, "tenant": ""}} # # The principal id is derived from the prefix, so nothing here is hand-authored @@ -14,7 +17,19 @@ # of content it does not hold, without naming a consumer's domain noun in its own # code, and which bounds the records the module may place at a scope node — and # `tenant` the org its Work Context is bound to, which is required and -# must be an organization id. `cross_tenant` lets an +# must be an organization id. +# +# `workload` is REQUIRED and all three of its parts are: it is the Kubernetes +# identity the module's pods run as, and the container within them that runs its +# image. Execution binding is unconditional, so this is what authenticates a +# module mint — the host reviews the caller's projected service-account token +# and refuses when the identity it attests is not the one declared here. Without +# it the prefix above would be a claim nothing checks, and a module holding its +# own valid token could mint as any other module it shares an image with. The +# container is declared rather than supplied by the caller because a pod's +# containers do not all run the same image, so naming one picks which image the +# approval is tested against. An entry missing any part of it fails the boot, +# naming the entry. `cross_tenant` lets an # inbox worker act on tenants other than that one, which is what servicing every # tenant's deliveries needs. Empty means no module may call the surface. # `read_audiences` and `operation_audiences` are installation-owned exchange diff --git a/module/configurations/local/policy-log.env b/module/configurations/local/policy-log.env new file mode 100644 index 000000000..3c67484d3 --- /dev/null +++ b/module/configurations/local/policy-log.env @@ -0,0 +1,16 @@ +# The independent witness's own storage and mirror, which belong to the +# WAREHOUSE's trust domain rather than to the database whose authority they +# witness. A restore of the accounts database must not restore the witness, so +# none of these may ever point at anything accounts can write. +# +# DELIBERATELY EMPTY, and this is the whole point of the file. The group has to +# ship a local default or the host cannot declare it at all, but a value here +# would be a development bypass: the witness refuses to boot without a real +# bucket and a real warehouse endpoint, and it must go on refusing locally. An +# empty value keeps that refusal and still satisfies the declaration. +# +# A real deployment supplies these through Codefly's configuration provider, +# against a dedicated bucket whose IAM forbids deleting or replacing anything +# under `entries/`. +POLICY_LOG_BUCKET= +POLICY_LOG_WAREHOUSE_ENDPOINT= diff --git a/module/configurations/local/policy-log.secret.env b/module/configurations/local/policy-log.secret.env new file mode 100644 index 000000000..63066514a --- /dev/null +++ b/module/configurations/local/policy-log.secret.env @@ -0,0 +1,9 @@ +# The witness's own warehouse credential. It is the witness's, not the host's: +# accounts must hold no warehouse writer capability at all, or a compromise of +# the database could rewrite the evidence that database's narrowings are +# witnessed by. +# +# A placeholder, never a usable value. Production supplies a unique, +# high-entropy token through Codefly's secret configuration provider, over +# HTTPS, with redirects refused. +POLICY_LOG_WAREHOUSE_TOKEN=local-dev-only-replace-me diff --git a/module/contracts/accounts/saas.events.codefly.yaml b/module/contracts/accounts/saas.events.codefly.yaml index ba5f79474..6818f7b89 100644 --- a/module/contracts/accounts/saas.events.codefly.yaml +++ b/module/contracts/accounts/saas.events.codefly.yaml @@ -591,15 +591,15 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d - - type: saas.solution.registration_deleted + - type: saas.solution.host_binding_applied schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d - - type: saas.solution.registration_minted + - type: saas.solution.registration_deleted schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d - - type: saas.solution.registration_updated + - type: saas.solution.registration_minted schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index e60ea95f0..043cef0e7 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/buf.yaml b/module/contracts/api/accounts/authority/proto/buf.yaml index 4a7e0e3cf..6cfad5207 100644 --- a/module/contracts/api/accounts/authority/proto/buf.yaml +++ b/module/contracts/api/accounts/authority/proto/buf.yaml @@ -44,6 +44,25 @@ breaking: # audit_export.proto is deleted; waive only the file-deletion rule for it. FILE_NO_DELETE: - saas/accounts/v1/audit_export.proto + # The solution-registration cold cutover (#952) deletes the whole + # runtime-asserted presence surface: a solution's presence is now DELIVERED + # by its declaration, so the writer RPCs a deployment used to heartbeat + # through, and the request/response and observation messages behind them, + # have no caller left. Legacy means delete, not deprecate — a retained + # writer is a second way to assert presence, which is the thing the cutover + # removes. Migration 26 drops the matching columns in the same change, so + # there is no window in which the old surface could be served. + # + # Each waiver below is scoped to the single rule the deletion trips. buf + # ignores are path-scoped, so a file is the narrowest expressible scope; the + # comments record what inside each file is intended to go. Every other + # breaking rule still guards these files, and the deletions are listed one + # by one in the pull request body. + ENUM_VALUE_NO_DELETE: + # SolutionRegistrationStatus.SOLUTION_REGISTRATION_STATUS_EXPIRED (3): + # derived from a lapsed lease, and there is no lease. Reserved in the + # proto so the number is never reused. + - saas/accounts/v1/solution_registry.proto # buf ignores are path-scoped, so neither entry can be narrowed to the single # message — the comments record the intended scope. MESSAGE_NO_DELETE: @@ -62,6 +81,11 @@ breaking: # Only AuditExportJob was dropped from jobs.proto. PrivacyExportJob's fields, # and every other rule, stay guarded. - saas/exports/v1/jobs.proto + # PutSolutionRegistrationRequest and DeleteSolutionRegistrationRequest are + # the deleted writer RPCs' messages; SolutionFrontendRegistration and + # SolutionBackendRegistration are the observations a deployment used to + # submit. All four belong to the removed writer surface (#952). + - saas/accounts/v1/solution_registry.proto # ListMyAccessibleScopes moved from PermissionService to the new # AccessibleScopeService in accessible_scopes.proto (issue #577), which changes # its Connect/gRPC procedure path. Its REST route (GET /v1/accessible-scopes) is @@ -69,6 +93,11 @@ breaking: # still fail on any RPC that disappears without the catalogs moving with it. RPC_NO_DELETE: - saas/accounts/v1/authorization.proto + # PutSolutionRegistration and DeleteSolutionRegistration: the registry is + # read-only to the wire now, written only by reconciling the declaration + # (#952). No replacement RPC takes their place, so nothing moved — the + # capability is gone. + - saas/accounts/v1/solution_registry_service.proto # target_collection (a free-text string that was neither resolvable nor # grantable) is replaced by a scope-node boundary binding across the three # datasource messages (issue #473). Waive only field deletion for this file; @@ -78,6 +107,21 @@ breaking: # nest) is replaced by a server-derived, UUID-labelled root node (issue #480). # Waive only field deletion for this file; every other breaking rule still # guards it. + # + # The FILE category flags a field deletion even when the number and name are + # reserved — reservation is honoured by FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED + # in the WIRE categories, which this module does not use. Every field below + # is reserved in its proto regardless: the waiver is what makes CI pass, the + # reservation is what stops the number being reused. FIELD_NO_DELETE: - saas/accounts/v1/datasource.proto - saas/accounts/v1/installations.proto + # SolutionFrontendBinding.lease_expires_at (4), + # SolutionBackendBinding.lease_expires_at (5) — presence is delivered, so + # nothing renews a lease — and SolutionRegistration.runtime_boundary (9), + # which is sealed into the Work Context instead of returned on a readable + # record (#952). + - saas/accounts/v1/solution_registry.proto + # ModuleCurrentInstallationResponse.solution_identifier (3): a reusable + # route alias, replaced by target_id and binding_id, which are identities. + - saas/accounts/v1/module_capabilities.proto diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto index de8a54b3e..2fe42d2ab 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto @@ -51,13 +51,42 @@ message Installation { string id = 1 [(buf.validate.field).string.uuid = true]; string org_id = 2 [(buf.validate.field).string.uuid = true]; string agent_principal_id = 3 [(buf.validate.field).string.uuid = true]; - string solution_identifier = 4; + // Was `solution_identifier`: the free-text route alias. It was the join key + // every reader used, and because an alias is deliberately reusable by a later + // binding, an installation that named one inherited whatever took it next. + // Replaced by target_id below. Reserved rather than reused so no consumer can + // read an alias out of a field that now carries an identity. + reserved 4; + reserved "solution_identifier"; string owner_principal_id = 5 [(buf.validate.field).string.uuid = true]; repeated string co_owner_principal_ids = 6; string root_scope_node_id = 7 [(buf.validate.field).string.uuid = true]; InstallationStatus status = 8; google.protobuf.Timestamp created_at = 9; optional google.protobuf.Timestamp revoked_at = 10; + // The immutable solution target this installation was consented to: one + // continuous period of one binding's presence on this host. It is never + // reused, so a replacement binding — even one claiming the same route alias — + // is a different target and inherits nothing. + // + // Deliberately NOT accompanied by the route alias. The alias is a property of + // the target that a later generation may move, and resolving it here would + // mean joining the host's presence state into a request-path listing — which + // would require granting request traffic read over every organisation's + // presence to answer about one. A consumer that needs the alias asks + // ListAvailableSolutions, which serves accepted applied state, and joins on + // this id. + // + // Empty only on a revoked historical row whose target could not be + // established at the cutover; an active installation always names one. + string target_id = 11 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; + // Why a revoked installation was revoked, when it was not an administrator + // uninstalling: the installation-identity cutover, or a tenancy narrowing that + // withdrew this organisation. Empty on an active row. + string revoked_reason = 12 [(buf.validate.field).string.max_len = 512]; } message InstallSolutionRequest { @@ -68,10 +97,11 @@ message InstallSolutionRequest { (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512 ]; - string solution_identifier = 3 [ - (buf.validate.field).string.min_len = 1, - (buf.validate.field).string.max_len = 512 - ]; + // Was `solution_identifier`: the route alias the installer typed. An alias is + // reusable, so installing by alias is installing whatever holds it at the + // moment the write lands — and whatever takes it later. Replaced by target_id. + reserved 3; + reserved "solution_identifier"; // Human display name for the agent principal. Empty defaults to the identifier. string display_name = 4 [(buf.validate.field).string.max_len = 512]; // The authority-root scope path is derived server-side from the created node id @@ -109,6 +139,14 @@ message InstallSolutionRequest { (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 128 ]; + // The solution target being installed, from ListAvailableSolutions. Required. + // + // An installer chooses an identity, never an address. The target must be LIVE + // and ACCEPTED when the install lands: installing a withdrawn target would + // record consent to a presence that already ended, and installing a target + // whose newest generation was refused would record consent to a release this + // host never admitted. + string target_id = 12 [(buf.validate.field).string.uuid = true]; } message UninstallSolutionRequest { @@ -139,6 +177,42 @@ message GetInstallationResponse { InstallationHealth health = 2; } +// ListInstallationsRequest asks what one organization has installed. Every other +// RPC on this service names a single installation, so until this existed nothing +// could answer "what has this org installed?" — which is the authority read a +// per-organization solution projection is built on (issue #949). +message ListInstallationsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // Restrict to one lifecycle status. UNSPECIFIED returns every status, so a + // caller that must see only live installs asks for ACTIVE explicitly rather + // than leaning on a default that would silently widen if the enum grew. + InstallationStatus status = 2; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +// InstallationSummary is one row of the listing: the installation, plus the health +// resolved live beside it exactly as GetInstallation resolves it for one. Health +// travels with the row because a consumer that renders an installed solution as +// unavailable would otherwise need one GetInstallation per installation. +message InstallationSummary { + Installation installation = 1; + InstallationHealth health = 2; +} + +message ListInstallationsResponse { + repeated InstallationSummary installations = 1; + // Set when more installations remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + // InstallationService composes and administers installations. The headless mint // that turns an installation into a short-lived Work Context is // WorkContextService.StartInstallationTask. @@ -255,4 +329,107 @@ service InstallationService { }; option (google.api.http) = {get: "/v1/installations/{installation_id}"}; } + + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + rpc ListInstallations(ListInstallationsRequest) returns (ListInstallationsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } + + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + rpc ListAvailableSolutions(ListAvailableSolutionsRequest) returns (ListAvailableSolutionsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_AUTHENTICATED + tenant: TENANT_REQUIREMENT_ORG_ADMIN + resource_bindings: { + request_field: "org_id" + target: RESOURCE_TARGET_ORGANIZATION + lookup: RESOURCE_LOOKUP_DIRECT_ID + } + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_STANDARD_READ + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + // Mounted under `/v1/installations`, a prefix the catalog already owns. + // A `/v1/solutions` route would have taken that prefix away from every + // composed module that may register one, which is a contract change with + // every downstream composer and not a side effect of adding a read. + option (google.api.http) = {get: "/v1/installations:listAvailable"}; + } +} + +// AvailableSolution is one installable solution target: its immutable identity, +// the release the host currently has applied for it, and where it is routed. +message AvailableSolution { + // The immutable target identity an install names. Never reused. + string target_id = 1 [(buf.validate.field).string.uuid = true]; + // The binding whose presence this target records. Carried so an operator can + // correlate a catalogue row with delivery; an installation does not join on it, + // because one binding may have several targets over time. + string binding_id = 2; + // The route alias the applied generation claims — `/solutions//*`. A + // later generation may move it, so it is never an identity. + string route_alias = 3; + // The applied release: publisher, name, version. + string release_publisher = 4; + string release_name = 5; + string release_version = 6; + // The generation that opened this target, and the newest applied generation. + // Equal until a later generation is applied for the same presence. + uint64 opened_generation = 7; + uint64 applied_generation = 8; + // True when this organization already has an ACTIVE installation of this + // target. A catalogue shows it as installed rather than offering it twice; the + // unique index over (org_id, target_id) would refuse the second install anyway, + // and an administrator should not have to discover that by being refused. + bool installed = 9; +} + +message ListAvailableSolutionsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped for the same reason the installation + // listing is capped. + int32 page_size = 2 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + string page_token = 3; +} + +message ListAvailableSolutionsResponse { + repeated AvailableSolution solutions = 1; + string next_page_token = 2; } diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto index 31fcc30f2..17ea81343 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto @@ -960,7 +960,20 @@ message ModuleCurrentInstallationRequest { message ModuleCurrentInstallationResponse { string installation_id = 1; string tenant_id = 2; - string solution_identifier = 3; + // Was `solution_identifier`: the route alias. The host cannot answer it on a + // request path — the alias lives on the host's presence state, and resolving + // it here would mean joining every organisation's presence to answer about + // one — and it is not an identity in any case, because a later binding may + // take the same route. Reserved rather than reused so no caller reads an + // identity out of a field that carried an alias. + reserved 3; + reserved "solution_identifier"; + // The immutable target this installation was consented to: one continuous + // period of one binding's presence, never reused. + string target_id = 4 [(buf.validate.field).string.uuid = true]; + // The delivered presence binding whose period that target records. This is + // what an authority decision keys on; the alias is display only. + string binding_id = 5; } service ModuleCapabilitiesService { diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements.proto new file mode 100644 index 000000000..dc048f8ee --- /dev/null +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// ============================================================================ +// Per-viewer solution entitlement (issue #949) +// +// A viewer should see the solutions their organization installed and their teams +// were granted, and nothing else. Answering that needs two authority facts read +// together: what the organization has installed (installations) and which of +// those installations' authority-root scope nodes the viewer may act on (the +// grant + share union every scope decision resolves through). +// +// They are answered by ONE read, in one org transaction, rather than by joining +// two RPCs in a caller: read separately, a revoke landing between the two calls +// yields a menu entry whose grant is already gone, and the revision a consumer +// caches on would describe no state that ever existed. +// +// These messages live in their own file with deliberately minimal imports — the +// same reason solution_registry.proto does — so the auth-gateway can generate +// the same types from the same source proto without vendoring the policy +// options. The service that serves them is +// solution_entitlements_service.proto, which accounts alone needs. +// ============================================================================ + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +message SolutionEntitlement { + // Was `solution_identifier`: the route alias, which consumers joined against + // their registered set. An alias is reusable by a later binding, so that join + // admitted a replacement to the predecessor's entitlement. Replaced by + // target_id below. + reserved 1; + reserved "solution_identifier"; + string installation_id = 2 [(buf.validate.field).string.uuid = true]; + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + string root_scope_node_id = 3 [(buf.validate.field).string.uuid = true]; + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + bool healthy = 4; + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + string target_id = 5 [(buf.validate.field).string.uuid = true]; + // Deliberately no platform-administrator basis field. The scope union admits a + // platform super_admin for the action `read` alone, and using a solution is not + // a read, so that branch can never contribute an entitlement here — a field + // reporting it would be permanently false. A super_admin sees an organization's + // installations through GetInstallation / ListInstallations, not by appearing to + // be entitled to them. +} + +message ListSolutionEntitlementsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + string subject_id = 2 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +message ListSolutionEntitlementsResponse { + repeated SolutionEntitlement entitlements = 1; + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + +// Deliberately NOT here: a revision or fingerprint field. A consumer that caches +// a projection built from these rows keys it on a digest of the rows it actually +// received, which is exact by construction. A revision computed server-side would +// have to describe either one page or a set larger than the caller read, and the +// paginated case makes the two disagree. The ordering is deterministic (keyset on +// installation id), so a consumer's own digest is stable across reads that return +// the same entitlements. diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements_service.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements_service.proto new file mode 100644 index 000000000..fa82633e1 --- /dev/null +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements_service.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "saas/accounts/v1/solution_entitlements.proto"; +import "saas/policy/v1/options.proto"; + +// The per-viewer solution entitlement service surface (issue #949). It is split +// from the messages in solution_entitlements.proto for the reason +// solution_registry_service.proto is split from its own: the auth-gateway is the +// only client and generates those messages without also vendoring the policy +// options this file needs. + +// SolutionEntitlementService answers what one viewer may use inside one +// organization. Internal-tier: it takes both the organization and the subject as +// request fields, so it is an authority oracle about an arbitrary principal and +// only a caller holding the cluster-internal credential may ask. The caller that +// does is the auth-gateway, naming the tenant and the viewer it projected from a +// verified identity — never values a browser supplied. +service SolutionEntitlementService { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + rpc ListSolutionEntitlements(ListSolutionEntitlementsRequest) returns (ListSolutionEntitlementsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } +} diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry.proto index 3cc97e45b..08933023d 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry.proto @@ -2,65 +2,47 @@ syntax = "proto3"; package saas.accounts.v1; -import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; -// ============================================================================ -// Durable solution registry (issue #534) -// -// A solution is an independently deployed component the host has no build-time -// knowledge of. It self-registers two halves at runtime: a frontend half (the -// Module-Federation manifest the host renders and navigates to) and a backend -// half (the upstream the gateway proxies /solutions//* to). Both used to be -// process-local maps, so a restart lost them, a registration behind a load -// balancer reached one replica only, and one half could be live while the other -// was missing. -// -// These messages live in their own file, with deliberately minimal imports, so -// the auth-gateway can generate the same types from the same source proto and -// call SolutionRegistryService (declared in solution_registry_service.proto) -// over the internal listener. Sharing the generated definition rather than -// restating the wire shape is what keeps the two sides from drifting. -// -// Two facts are deliberately distinct. Durable installation intent is the -// record existing and not being tombstoned; ephemeral endpoint liveness is the -// per-half lease, renewed by whoever registered that half. A lapsed lease -// suspends routing without discarding the registration, and the derived status -// says which of the two is at fault. -// ============================================================================ +// Read-only projection of the host's declared solution presence. The gateway +// shares these messages and reads the accounts internal listener. -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. enum SolutionRegistrationStatus { SOLUTION_REGISTRATION_STATUS_UNSPECIFIED = 0; - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SOLUTION_REGISTRATION_STATUS_PENDING = 2; - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; + // Was `SOLUTION_REGISTRATION_STATUS_EXPIRED`: both halves registered but a + // lease had lapsed. Presence is delivered, never heartbeat-asserted, so there + // is no lease to lapse and no status to derive from one. Reserved rather than + // reused: an old reader decoding 3 would read "expired" out of whatever + // meaning a later value gave the number. + reserved 3; + reserved "SOLUTION_REGISTRATION_STATUS_EXPIRED"; // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE = 4; - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; } // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. message SolutionFrontendBinding { int64 revision = 1; string manifest = 2; string contract_version = 3; - google.protobuf.Timestamp lease_expires_at = 4; + // Was `google.protobuf.Timestamp lease_expires_at`: the renewal path is + // deleted, so a column nothing renews was legacy. Reserved, not reused — a + // released number never takes another name or type. + reserved 4; + reserved "lease_expires_at"; } // SolutionBackendBinding is the stored backend half: the upstream the gateway @@ -70,7 +52,10 @@ message SolutionBackendBinding { string upstream = 2; string service_alias = 3; string contract_version = 4; - google.protobuf.Timestamp lease_expires_at = 5; + // Was `google.protobuf.Timestamp lease_expires_at`: see + // SolutionFrontendBinding above. Reserved, not reused. + reserved 5; + reserved "lease_expires_at"; } // SolutionRegistration is the canonical record. revision is drawn from one @@ -78,8 +63,7 @@ message SolutionBackendBinding { // consumer can order two snapshots without a wall clock. message SolutionRegistration { string solution_id = 1; - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. string publisher = 2; int64 revision = 3; SolutionRegistrationStatus status = 4; @@ -87,91 +71,20 @@ message SolutionRegistration { optional SolutionBackendBinding backend = 6; google.protobuf.Timestamp updated_at = 7; optional google.protobuf.Timestamp tombstoned_at = 8; - // The seed a solution's runtime boundary is derived from (issue #1015). + // Was `string runtime_boundary`: the opaque id every Work Context minted for + // this solution is sealed under. It is NOT a field a readable record carries + // — a boundary returned on a readable record is a capability leaking as a + // label — so it is deleted from the wire rather than renamed, and the host + // seals it into the capability instead. // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - string runtime_boundary = 9; -} - -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -message SolutionFrontendRegistration { - string manifest = 1 [(buf.validate.field).string = { - min_len: 2 - max_len: 262144 - }]; - string contract_version = 2 [(buf.validate.field).string.max_len = 128]; -} - -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -message SolutionBackendRegistration { - string upstream = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 2048 - uri: true - }]; - string service_alias = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - string contract_version = 3 [(buf.validate.field).string.max_len = 128]; -} - -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -message PutSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - pattern: "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$" - }]; - string publisher = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 256 - }]; - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - optional int64 expected_revision = 3 [(buf.validate.field).int64.gt = 0]; - // Requested liveness window for this half. The registrant renews within it. - uint32 lease_seconds = 4 [(buf.validate.field).uint32 = { - gte: 30 - lte: 3600 - }]; - oneof half { - option (buf.validate.oneof).required = true; - SolutionFrontendRegistration frontend = 5; - SolutionBackendRegistration backend = 6; - } -} - -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -message DeleteSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - optional int64 expected_revision = 2 [(buf.validate.field).int64.gt = 0]; + // Reserved rather than reused. A released field number never takes another + // name or type: a consumer still on the old schema reading 9 would decode a + // message where it expected a string. `declared` therefore took 10, which + // cost a digest move and is the price of not corrupting an old reader. + reserved 9; + reserved "runtime_boundary"; + // The binding and immutable target that declared this record. + optional SolutionDeclaredBinding declared = 10; } // ListSolutionRegistrationsRequest reads the whole registry. There is one @@ -189,3 +102,127 @@ message ListSolutionRegistrationsResponse { // report the same registry_revision have converged on the same state. int64 registry_revision = 2; } + +// ============================================================================ +// Declared solution presence (issue #952) +// +// A SolutionHostBinding is the document delivery hands this host: which solution +// runs here, at which generation. The host reconciles it into the registration +// record above. These messages are the read surface, and they keep the three +// facts apart on purpose — an operator's first question is which of them is +// wrong. +// ============================================================================ + +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +enum SolutionDeclaredKind { + SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SOLUTION_DECLARED_KIND_SOLUTION = 1; + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SOLUTION_DECLARED_KIND_MODULE = 2; +} + +// SolutionDeclaredBinding identifies the applied declaration and target. +message SolutionDeclaredBinding { + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + string binding_id = 1; + // The generation of that binding the host applied into this record. + uint64 generation = 2; + // publisher/name@version of the applied generation. + string release = 3; + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. + // + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + string target_id = 4; + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + SolutionDeclaredKind kind = 5; +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +message SolutionHostBindingGeneration { + uint64 generation = 1; + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + string digest = 2; + string document = 3; + google.protobuf.Timestamp at = 4; +} + +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +message SolutionHostBindingAppliedGeneration { + SolutionHostBindingGeneration generation = 1; + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + bool removed = 2; + // The route aliases the applied generation holds. A tombstone holds none. + repeated string routes = 3; + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + string solution_id = 4; + string release = 5; +} + +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +message SolutionHostBindingState { + string binding_id = 1; + string host_coordinate = 2; + string host_component = 3; + // The newest generation delivery has shown this host, whether or not it + // passed. + optional SolutionHostBindingGeneration desired = 4; + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + optional SolutionHostBindingAppliedGeneration applied = 5; + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + uint64 pending_generation = 6; + // Why the pending generation was not applied. Empty when nothing is pending. + string pending_reason = 7; + optional google.protobuf.Timestamp pending_since = 8; + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + optional SolutionRegistration registration = 9; + google.protobuf.Timestamp updated_at = 10; +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +message ListSolutionHostBindingsRequest {} + +message ListSolutionHostBindingsResponse { + repeated SolutionHostBindingState bindings = 1; +} diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry_service.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry_service.proto index a86768d41..61b1a901d 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry_service.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_registry_service.proto @@ -5,48 +5,20 @@ package saas.accounts.v1; import "saas/accounts/v1/solution_registry.proto"; import "saas/policy/v1/options.proto"; -// The durable solution registry's service surface (issue #534). It is split -// from the messages in solution_registry.proto so the auth-gateway — the only -// client — can generate those messages without also vendoring the policy -// options this file needs. -// -// Every method is EXPOSURE_INTERNAL: the gateway reaches them over the accounts -// internal listener, and it brokers the frontend's half as well as its own, so -// the frontend holds no second connection to accounts. - -// SolutionRegistryService is the durable authority behind both self-registration -// surfaces. Every method is internal: the gateway is the only client, and it -// brokers the frontend's half as well as its own. +// Internal read service for declared solution state. The gateway is the client +// and brokers the registry projection to the frontend. service SolutionRegistryService { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - rpc PutSolutionRegistration(PutSolutionRegistrationRequest) returns (SolutionRegistration) { + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + rpc ListSolutionHostBindings(ListSolutionHostBindingsRequest) returns (ListSolutionHostBindingsResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_INTERNAL tenant: TENANT_REQUIREMENT_NONE mfa: MFA_REQUIREMENT_NONE - audit: { - events: "saas.solution.registration_updated" - emission: AUDIT_EMISSION_SUCCESS - } - idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN - rate_limit: RATE_LIMIT_CLASS_INTERNAL - request_sensitivity: SENSITIVITY_CONFIDENTIAL - response_sensitivity: SENSITIVITY_CONFIDENTIAL - platform_role: PLATFORM_ROLE_REQUIREMENT_NONE - }; - } - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - rpc DeleteSolutionRegistration(DeleteSolutionRegistrationRequest) returns (SolutionRegistration) { - option (saas.policy.v1.method_policy) = { - exposure: EXPOSURE_INTERNAL - tenant: TENANT_REQUIREMENT_NONE - mfa: MFA_REQUIREMENT_NONE - audit: { - events: "saas.solution.registration_deleted" - emission: AUDIT_EMISSION_SUCCESS - } + audit: {emission: AUDIT_EMISSION_NONE} idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN rate_limit: RATE_LIMIT_CLASS_INTERNAL request_sensitivity: SENSITIVITY_CONFIDENTIAL diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index e60ea95f0..043cef0e7 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/buf.yaml b/module/contracts/api/accounts/connect/proto/buf.yaml index 4a7e0e3cf..6cfad5207 100644 --- a/module/contracts/api/accounts/connect/proto/buf.yaml +++ b/module/contracts/api/accounts/connect/proto/buf.yaml @@ -44,6 +44,25 @@ breaking: # audit_export.proto is deleted; waive only the file-deletion rule for it. FILE_NO_DELETE: - saas/accounts/v1/audit_export.proto + # The solution-registration cold cutover (#952) deletes the whole + # runtime-asserted presence surface: a solution's presence is now DELIVERED + # by its declaration, so the writer RPCs a deployment used to heartbeat + # through, and the request/response and observation messages behind them, + # have no caller left. Legacy means delete, not deprecate — a retained + # writer is a second way to assert presence, which is the thing the cutover + # removes. Migration 26 drops the matching columns in the same change, so + # there is no window in which the old surface could be served. + # + # Each waiver below is scoped to the single rule the deletion trips. buf + # ignores are path-scoped, so a file is the narrowest expressible scope; the + # comments record what inside each file is intended to go. Every other + # breaking rule still guards these files, and the deletions are listed one + # by one in the pull request body. + ENUM_VALUE_NO_DELETE: + # SolutionRegistrationStatus.SOLUTION_REGISTRATION_STATUS_EXPIRED (3): + # derived from a lapsed lease, and there is no lease. Reserved in the + # proto so the number is never reused. + - saas/accounts/v1/solution_registry.proto # buf ignores are path-scoped, so neither entry can be narrowed to the single # message — the comments record the intended scope. MESSAGE_NO_DELETE: @@ -62,6 +81,11 @@ breaking: # Only AuditExportJob was dropped from jobs.proto. PrivacyExportJob's fields, # and every other rule, stay guarded. - saas/exports/v1/jobs.proto + # PutSolutionRegistrationRequest and DeleteSolutionRegistrationRequest are + # the deleted writer RPCs' messages; SolutionFrontendRegistration and + # SolutionBackendRegistration are the observations a deployment used to + # submit. All four belong to the removed writer surface (#952). + - saas/accounts/v1/solution_registry.proto # ListMyAccessibleScopes moved from PermissionService to the new # AccessibleScopeService in accessible_scopes.proto (issue #577), which changes # its Connect/gRPC procedure path. Its REST route (GET /v1/accessible-scopes) is @@ -69,6 +93,11 @@ breaking: # still fail on any RPC that disappears without the catalogs moving with it. RPC_NO_DELETE: - saas/accounts/v1/authorization.proto + # PutSolutionRegistration and DeleteSolutionRegistration: the registry is + # read-only to the wire now, written only by reconciling the declaration + # (#952). No replacement RPC takes their place, so nothing moved — the + # capability is gone. + - saas/accounts/v1/solution_registry_service.proto # target_collection (a free-text string that was neither resolvable nor # grantable) is replaced by a scope-node boundary binding across the three # datasource messages (issue #473). Waive only field deletion for this file; @@ -78,6 +107,21 @@ breaking: # nest) is replaced by a server-derived, UUID-labelled root node (issue #480). # Waive only field deletion for this file; every other breaking rule still # guards it. + # + # The FILE category flags a field deletion even when the number and name are + # reserved — reservation is honoured by FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED + # in the WIRE categories, which this module does not use. Every field below + # is reserved in its proto regardless: the waiver is what makes CI pass, the + # reservation is what stops the number being reused. FIELD_NO_DELETE: - saas/accounts/v1/datasource.proto - saas/accounts/v1/installations.proto + # SolutionFrontendBinding.lease_expires_at (4), + # SolutionBackendBinding.lease_expires_at (5) — presence is delivered, so + # nothing renews a lease — and SolutionRegistration.runtime_boundary (9), + # which is sealed into the Work Context instead of returned on a readable + # record (#952). + - saas/accounts/v1/solution_registry.proto + # ModuleCurrentInstallationResponse.solution_identifier (3): a reusable + # route alias, replaced by target_id and binding_id, which are identities. + - saas/accounts/v1/module_capabilities.proto diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index de8a54b3e..2fe42d2ab 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto @@ -51,13 +51,42 @@ message Installation { string id = 1 [(buf.validate.field).string.uuid = true]; string org_id = 2 [(buf.validate.field).string.uuid = true]; string agent_principal_id = 3 [(buf.validate.field).string.uuid = true]; - string solution_identifier = 4; + // Was `solution_identifier`: the free-text route alias. It was the join key + // every reader used, and because an alias is deliberately reusable by a later + // binding, an installation that named one inherited whatever took it next. + // Replaced by target_id below. Reserved rather than reused so no consumer can + // read an alias out of a field that now carries an identity. + reserved 4; + reserved "solution_identifier"; string owner_principal_id = 5 [(buf.validate.field).string.uuid = true]; repeated string co_owner_principal_ids = 6; string root_scope_node_id = 7 [(buf.validate.field).string.uuid = true]; InstallationStatus status = 8; google.protobuf.Timestamp created_at = 9; optional google.protobuf.Timestamp revoked_at = 10; + // The immutable solution target this installation was consented to: one + // continuous period of one binding's presence on this host. It is never + // reused, so a replacement binding — even one claiming the same route alias — + // is a different target and inherits nothing. + // + // Deliberately NOT accompanied by the route alias. The alias is a property of + // the target that a later generation may move, and resolving it here would + // mean joining the host's presence state into a request-path listing — which + // would require granting request traffic read over every organisation's + // presence to answer about one. A consumer that needs the alias asks + // ListAvailableSolutions, which serves accepted applied state, and joins on + // this id. + // + // Empty only on a revoked historical row whose target could not be + // established at the cutover; an active installation always names one. + string target_id = 11 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; + // Why a revoked installation was revoked, when it was not an administrator + // uninstalling: the installation-identity cutover, or a tenancy narrowing that + // withdrew this organisation. Empty on an active row. + string revoked_reason = 12 [(buf.validate.field).string.max_len = 512]; } message InstallSolutionRequest { @@ -68,10 +97,11 @@ message InstallSolutionRequest { (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512 ]; - string solution_identifier = 3 [ - (buf.validate.field).string.min_len = 1, - (buf.validate.field).string.max_len = 512 - ]; + // Was `solution_identifier`: the route alias the installer typed. An alias is + // reusable, so installing by alias is installing whatever holds it at the + // moment the write lands — and whatever takes it later. Replaced by target_id. + reserved 3; + reserved "solution_identifier"; // Human display name for the agent principal. Empty defaults to the identifier. string display_name = 4 [(buf.validate.field).string.max_len = 512]; // The authority-root scope path is derived server-side from the created node id @@ -109,6 +139,14 @@ message InstallSolutionRequest { (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 128 ]; + // The solution target being installed, from ListAvailableSolutions. Required. + // + // An installer chooses an identity, never an address. The target must be LIVE + // and ACCEPTED when the install lands: installing a withdrawn target would + // record consent to a presence that already ended, and installing a target + // whose newest generation was refused would record consent to a release this + // host never admitted. + string target_id = 12 [(buf.validate.field).string.uuid = true]; } message UninstallSolutionRequest { @@ -139,6 +177,42 @@ message GetInstallationResponse { InstallationHealth health = 2; } +// ListInstallationsRequest asks what one organization has installed. Every other +// RPC on this service names a single installation, so until this existed nothing +// could answer "what has this org installed?" — which is the authority read a +// per-organization solution projection is built on (issue #949). +message ListInstallationsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // Restrict to one lifecycle status. UNSPECIFIED returns every status, so a + // caller that must see only live installs asks for ACTIVE explicitly rather + // than leaning on a default that would silently widen if the enum grew. + InstallationStatus status = 2; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +// InstallationSummary is one row of the listing: the installation, plus the health +// resolved live beside it exactly as GetInstallation resolves it for one. Health +// travels with the row because a consumer that renders an installed solution as +// unavailable would otherwise need one GetInstallation per installation. +message InstallationSummary { + Installation installation = 1; + InstallationHealth health = 2; +} + +message ListInstallationsResponse { + repeated InstallationSummary installations = 1; + // Set when more installations remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + // InstallationService composes and administers installations. The headless mint // that turns an installation into a short-lived Work Context is // WorkContextService.StartInstallationTask. @@ -255,4 +329,107 @@ service InstallationService { }; option (google.api.http) = {get: "/v1/installations/{installation_id}"}; } + + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + rpc ListInstallations(ListInstallationsRequest) returns (ListInstallationsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } + + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + rpc ListAvailableSolutions(ListAvailableSolutionsRequest) returns (ListAvailableSolutionsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_AUTHENTICATED + tenant: TENANT_REQUIREMENT_ORG_ADMIN + resource_bindings: { + request_field: "org_id" + target: RESOURCE_TARGET_ORGANIZATION + lookup: RESOURCE_LOOKUP_DIRECT_ID + } + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_STANDARD_READ + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + // Mounted under `/v1/installations`, a prefix the catalog already owns. + // A `/v1/solutions` route would have taken that prefix away from every + // composed module that may register one, which is a contract change with + // every downstream composer and not a side effect of adding a read. + option (google.api.http) = {get: "/v1/installations:listAvailable"}; + } +} + +// AvailableSolution is one installable solution target: its immutable identity, +// the release the host currently has applied for it, and where it is routed. +message AvailableSolution { + // The immutable target identity an install names. Never reused. + string target_id = 1 [(buf.validate.field).string.uuid = true]; + // The binding whose presence this target records. Carried so an operator can + // correlate a catalogue row with delivery; an installation does not join on it, + // because one binding may have several targets over time. + string binding_id = 2; + // The route alias the applied generation claims — `/solutions//*`. A + // later generation may move it, so it is never an identity. + string route_alias = 3; + // The applied release: publisher, name, version. + string release_publisher = 4; + string release_name = 5; + string release_version = 6; + // The generation that opened this target, and the newest applied generation. + // Equal until a later generation is applied for the same presence. + uint64 opened_generation = 7; + uint64 applied_generation = 8; + // True when this organization already has an ACTIVE installation of this + // target. A catalogue shows it as installed rather than offering it twice; the + // unique index over (org_id, target_id) would refuse the second install anyway, + // and an administrator should not have to discover that by being refused. + bool installed = 9; +} + +message ListAvailableSolutionsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped for the same reason the installation + // listing is capped. + int32 page_size = 2 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + string page_token = 3; +} + +message ListAvailableSolutionsResponse { + repeated AvailableSolution solutions = 1; + string next_page_token = 2; } diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto index 31fcc30f2..17ea81343 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto @@ -960,7 +960,20 @@ message ModuleCurrentInstallationRequest { message ModuleCurrentInstallationResponse { string installation_id = 1; string tenant_id = 2; - string solution_identifier = 3; + // Was `solution_identifier`: the route alias. The host cannot answer it on a + // request path — the alias lives on the host's presence state, and resolving + // it here would mean joining every organisation's presence to answer about + // one — and it is not an identity in any case, because a later binding may + // take the same route. Reserved rather than reused so no caller reads an + // identity out of a field that carried an alias. + reserved 3; + reserved "solution_identifier"; + // The immutable target this installation was consented to: one continuous + // period of one binding's presence, never reused. + string target_id = 4 [(buf.validate.field).string.uuid = true]; + // The delivered presence binding whose period that target records. This is + // what an authority decision keys on; the alias is display only. + string binding_id = 5; } service ModuleCapabilitiesService { diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements.proto new file mode 100644 index 000000000..dc048f8ee --- /dev/null +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// ============================================================================ +// Per-viewer solution entitlement (issue #949) +// +// A viewer should see the solutions their organization installed and their teams +// were granted, and nothing else. Answering that needs two authority facts read +// together: what the organization has installed (installations) and which of +// those installations' authority-root scope nodes the viewer may act on (the +// grant + share union every scope decision resolves through). +// +// They are answered by ONE read, in one org transaction, rather than by joining +// two RPCs in a caller: read separately, a revoke landing between the two calls +// yields a menu entry whose grant is already gone, and the revision a consumer +// caches on would describe no state that ever existed. +// +// These messages live in their own file with deliberately minimal imports — the +// same reason solution_registry.proto does — so the auth-gateway can generate +// the same types from the same source proto without vendoring the policy +// options. The service that serves them is +// solution_entitlements_service.proto, which accounts alone needs. +// ============================================================================ + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +message SolutionEntitlement { + // Was `solution_identifier`: the route alias, which consumers joined against + // their registered set. An alias is reusable by a later binding, so that join + // admitted a replacement to the predecessor's entitlement. Replaced by + // target_id below. + reserved 1; + reserved "solution_identifier"; + string installation_id = 2 [(buf.validate.field).string.uuid = true]; + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + string root_scope_node_id = 3 [(buf.validate.field).string.uuid = true]; + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + bool healthy = 4; + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + string target_id = 5 [(buf.validate.field).string.uuid = true]; + // Deliberately no platform-administrator basis field. The scope union admits a + // platform super_admin for the action `read` alone, and using a solution is not + // a read, so that branch can never contribute an entitlement here — a field + // reporting it would be permanently false. A super_admin sees an organization's + // installations through GetInstallation / ListInstallations, not by appearing to + // be entitled to them. +} + +message ListSolutionEntitlementsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + string subject_id = 2 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +message ListSolutionEntitlementsResponse { + repeated SolutionEntitlement entitlements = 1; + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + +// Deliberately NOT here: a revision or fingerprint field. A consumer that caches +// a projection built from these rows keys it on a digest of the rows it actually +// received, which is exact by construction. A revision computed server-side would +// have to describe either one page or a set larger than the caller read, and the +// paginated case makes the two disagree. The ordering is deterministic (keyset on +// installation id), so a consumer's own digest is stable across reads that return +// the same entitlements. diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements_service.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements_service.proto new file mode 100644 index 000000000..fa82633e1 --- /dev/null +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements_service.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "saas/accounts/v1/solution_entitlements.proto"; +import "saas/policy/v1/options.proto"; + +// The per-viewer solution entitlement service surface (issue #949). It is split +// from the messages in solution_entitlements.proto for the reason +// solution_registry_service.proto is split from its own: the auth-gateway is the +// only client and generates those messages without also vendoring the policy +// options this file needs. + +// SolutionEntitlementService answers what one viewer may use inside one +// organization. Internal-tier: it takes both the organization and the subject as +// request fields, so it is an authority oracle about an arbitrary principal and +// only a caller holding the cluster-internal credential may ask. The caller that +// does is the auth-gateway, naming the tenant and the viewer it projected from a +// verified identity — never values a browser supplied. +service SolutionEntitlementService { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + rpc ListSolutionEntitlements(ListSolutionEntitlementsRequest) returns (ListSolutionEntitlementsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } +} diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry.proto index 3cc97e45b..08933023d 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry.proto @@ -2,65 +2,47 @@ syntax = "proto3"; package saas.accounts.v1; -import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; -// ============================================================================ -// Durable solution registry (issue #534) -// -// A solution is an independently deployed component the host has no build-time -// knowledge of. It self-registers two halves at runtime: a frontend half (the -// Module-Federation manifest the host renders and navigates to) and a backend -// half (the upstream the gateway proxies /solutions//* to). Both used to be -// process-local maps, so a restart lost them, a registration behind a load -// balancer reached one replica only, and one half could be live while the other -// was missing. -// -// These messages live in their own file, with deliberately minimal imports, so -// the auth-gateway can generate the same types from the same source proto and -// call SolutionRegistryService (declared in solution_registry_service.proto) -// over the internal listener. Sharing the generated definition rather than -// restating the wire shape is what keeps the two sides from drifting. -// -// Two facts are deliberately distinct. Durable installation intent is the -// record existing and not being tombstoned; ephemeral endpoint liveness is the -// per-half lease, renewed by whoever registered that half. A lapsed lease -// suspends routing without discarding the registration, and the derived status -// says which of the two is at fault. -// ============================================================================ +// Read-only projection of the host's declared solution presence. The gateway +// shares these messages and reads the accounts internal listener. -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. enum SolutionRegistrationStatus { SOLUTION_REGISTRATION_STATUS_UNSPECIFIED = 0; - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SOLUTION_REGISTRATION_STATUS_PENDING = 2; - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; + // Was `SOLUTION_REGISTRATION_STATUS_EXPIRED`: both halves registered but a + // lease had lapsed. Presence is delivered, never heartbeat-asserted, so there + // is no lease to lapse and no status to derive from one. Reserved rather than + // reused: an old reader decoding 3 would read "expired" out of whatever + // meaning a later value gave the number. + reserved 3; + reserved "SOLUTION_REGISTRATION_STATUS_EXPIRED"; // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE = 4; - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; } // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. message SolutionFrontendBinding { int64 revision = 1; string manifest = 2; string contract_version = 3; - google.protobuf.Timestamp lease_expires_at = 4; + // Was `google.protobuf.Timestamp lease_expires_at`: the renewal path is + // deleted, so a column nothing renews was legacy. Reserved, not reused — a + // released number never takes another name or type. + reserved 4; + reserved "lease_expires_at"; } // SolutionBackendBinding is the stored backend half: the upstream the gateway @@ -70,7 +52,10 @@ message SolutionBackendBinding { string upstream = 2; string service_alias = 3; string contract_version = 4; - google.protobuf.Timestamp lease_expires_at = 5; + // Was `google.protobuf.Timestamp lease_expires_at`: see + // SolutionFrontendBinding above. Reserved, not reused. + reserved 5; + reserved "lease_expires_at"; } // SolutionRegistration is the canonical record. revision is drawn from one @@ -78,8 +63,7 @@ message SolutionBackendBinding { // consumer can order two snapshots without a wall clock. message SolutionRegistration { string solution_id = 1; - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. string publisher = 2; int64 revision = 3; SolutionRegistrationStatus status = 4; @@ -87,91 +71,20 @@ message SolutionRegistration { optional SolutionBackendBinding backend = 6; google.protobuf.Timestamp updated_at = 7; optional google.protobuf.Timestamp tombstoned_at = 8; - // The seed a solution's runtime boundary is derived from (issue #1015). + // Was `string runtime_boundary`: the opaque id every Work Context minted for + // this solution is sealed under. It is NOT a field a readable record carries + // — a boundary returned on a readable record is a capability leaking as a + // label — so it is deleted from the wire rather than renamed, and the host + // seals it into the capability instead. // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - string runtime_boundary = 9; -} - -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -message SolutionFrontendRegistration { - string manifest = 1 [(buf.validate.field).string = { - min_len: 2 - max_len: 262144 - }]; - string contract_version = 2 [(buf.validate.field).string.max_len = 128]; -} - -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -message SolutionBackendRegistration { - string upstream = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 2048 - uri: true - }]; - string service_alias = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - string contract_version = 3 [(buf.validate.field).string.max_len = 128]; -} - -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -message PutSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - pattern: "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$" - }]; - string publisher = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 256 - }]; - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - optional int64 expected_revision = 3 [(buf.validate.field).int64.gt = 0]; - // Requested liveness window for this half. The registrant renews within it. - uint32 lease_seconds = 4 [(buf.validate.field).uint32 = { - gte: 30 - lte: 3600 - }]; - oneof half { - option (buf.validate.oneof).required = true; - SolutionFrontendRegistration frontend = 5; - SolutionBackendRegistration backend = 6; - } -} - -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -message DeleteSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - optional int64 expected_revision = 2 [(buf.validate.field).int64.gt = 0]; + // Reserved rather than reused. A released field number never takes another + // name or type: a consumer still on the old schema reading 9 would decode a + // message where it expected a string. `declared` therefore took 10, which + // cost a digest move and is the price of not corrupting an old reader. + reserved 9; + reserved "runtime_boundary"; + // The binding and immutable target that declared this record. + optional SolutionDeclaredBinding declared = 10; } // ListSolutionRegistrationsRequest reads the whole registry. There is one @@ -189,3 +102,127 @@ message ListSolutionRegistrationsResponse { // report the same registry_revision have converged on the same state. int64 registry_revision = 2; } + +// ============================================================================ +// Declared solution presence (issue #952) +// +// A SolutionHostBinding is the document delivery hands this host: which solution +// runs here, at which generation. The host reconciles it into the registration +// record above. These messages are the read surface, and they keep the three +// facts apart on purpose — an operator's first question is which of them is +// wrong. +// ============================================================================ + +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +enum SolutionDeclaredKind { + SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SOLUTION_DECLARED_KIND_SOLUTION = 1; + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SOLUTION_DECLARED_KIND_MODULE = 2; +} + +// SolutionDeclaredBinding identifies the applied declaration and target. +message SolutionDeclaredBinding { + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + string binding_id = 1; + // The generation of that binding the host applied into this record. + uint64 generation = 2; + // publisher/name@version of the applied generation. + string release = 3; + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. + // + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + string target_id = 4; + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + SolutionDeclaredKind kind = 5; +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +message SolutionHostBindingGeneration { + uint64 generation = 1; + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + string digest = 2; + string document = 3; + google.protobuf.Timestamp at = 4; +} + +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +message SolutionHostBindingAppliedGeneration { + SolutionHostBindingGeneration generation = 1; + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + bool removed = 2; + // The route aliases the applied generation holds. A tombstone holds none. + repeated string routes = 3; + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + string solution_id = 4; + string release = 5; +} + +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +message SolutionHostBindingState { + string binding_id = 1; + string host_coordinate = 2; + string host_component = 3; + // The newest generation delivery has shown this host, whether or not it + // passed. + optional SolutionHostBindingGeneration desired = 4; + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + optional SolutionHostBindingAppliedGeneration applied = 5; + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + uint64 pending_generation = 6; + // Why the pending generation was not applied. Empty when nothing is pending. + string pending_reason = 7; + optional google.protobuf.Timestamp pending_since = 8; + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + optional SolutionRegistration registration = 9; + google.protobuf.Timestamp updated_at = 10; +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +message ListSolutionHostBindingsRequest {} + +message ListSolutionHostBindingsResponse { + repeated SolutionHostBindingState bindings = 1; +} diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry_service.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry_service.proto index a86768d41..61b1a901d 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry_service.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_registry_service.proto @@ -5,48 +5,20 @@ package saas.accounts.v1; import "saas/accounts/v1/solution_registry.proto"; import "saas/policy/v1/options.proto"; -// The durable solution registry's service surface (issue #534). It is split -// from the messages in solution_registry.proto so the auth-gateway — the only -// client — can generate those messages without also vendoring the policy -// options this file needs. -// -// Every method is EXPOSURE_INTERNAL: the gateway reaches them over the accounts -// internal listener, and it brokers the frontend's half as well as its own, so -// the frontend holds no second connection to accounts. - -// SolutionRegistryService is the durable authority behind both self-registration -// surfaces. Every method is internal: the gateway is the only client, and it -// brokers the frontend's half as well as its own. +// Internal read service for declared solution state. The gateway is the client +// and brokers the registry projection to the frontend. service SolutionRegistryService { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - rpc PutSolutionRegistration(PutSolutionRegistrationRequest) returns (SolutionRegistration) { + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + rpc ListSolutionHostBindings(ListSolutionHostBindingsRequest) returns (ListSolutionHostBindingsResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_INTERNAL tenant: TENANT_REQUIREMENT_NONE mfa: MFA_REQUIREMENT_NONE - audit: { - events: "saas.solution.registration_updated" - emission: AUDIT_EMISSION_SUCCESS - } - idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN - rate_limit: RATE_LIMIT_CLASS_INTERNAL - request_sensitivity: SENSITIVITY_CONFIDENTIAL - response_sensitivity: SENSITIVITY_CONFIDENTIAL - platform_role: PLATFORM_ROLE_REQUIREMENT_NONE - }; - } - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - rpc DeleteSolutionRegistration(DeleteSolutionRegistrationRequest) returns (SolutionRegistration) { - option (saas.policy.v1.method_policy) = { - exposure: EXPOSURE_INTERNAL - tenant: TENANT_REQUIREMENT_NONE - mfa: MFA_REQUIREMENT_NONE - audit: { - events: "saas.solution.registration_deleted" - emission: AUDIT_EMISSION_SUCCESS - } + audit: {emission: AUDIT_EMISSION_NONE} idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN rate_limit: RATE_LIMIT_CLASS_INTERNAL request_sensitivity: SENSITIVITY_CONFIDENTIAL diff --git a/module/contracts/api/auth-gateway/grpc/contract.binpb b/module/contracts/api/auth-gateway/grpc/contract.binpb index 86509cdf9..0ec578199 100644 Binary files a/module/contracts/api/auth-gateway/grpc/contract.binpb and b/module/contracts/api/auth-gateway/grpc/contract.binpb differ diff --git a/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto new file mode 100644 index 000000000..dc048f8ee --- /dev/null +++ b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// ============================================================================ +// Per-viewer solution entitlement (issue #949) +// +// A viewer should see the solutions their organization installed and their teams +// were granted, and nothing else. Answering that needs two authority facts read +// together: what the organization has installed (installations) and which of +// those installations' authority-root scope nodes the viewer may act on (the +// grant + share union every scope decision resolves through). +// +// They are answered by ONE read, in one org transaction, rather than by joining +// two RPCs in a caller: read separately, a revoke landing between the two calls +// yields a menu entry whose grant is already gone, and the revision a consumer +// caches on would describe no state that ever existed. +// +// These messages live in their own file with deliberately minimal imports — the +// same reason solution_registry.proto does — so the auth-gateway can generate +// the same types from the same source proto without vendoring the policy +// options. The service that serves them is +// solution_entitlements_service.proto, which accounts alone needs. +// ============================================================================ + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +message SolutionEntitlement { + // Was `solution_identifier`: the route alias, which consumers joined against + // their registered set. An alias is reusable by a later binding, so that join + // admitted a replacement to the predecessor's entitlement. Replaced by + // target_id below. + reserved 1; + reserved "solution_identifier"; + string installation_id = 2 [(buf.validate.field).string.uuid = true]; + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + string root_scope_node_id = 3 [(buf.validate.field).string.uuid = true]; + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + bool healthy = 4; + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + string target_id = 5 [(buf.validate.field).string.uuid = true]; + // Deliberately no platform-administrator basis field. The scope union admits a + // platform super_admin for the action `read` alone, and using a solution is not + // a read, so that branch can never contribute an entitlement here — a field + // reporting it would be permanently false. A super_admin sees an organization's + // installations through GetInstallation / ListInstallations, not by appearing to + // be entitled to them. +} + +message ListSolutionEntitlementsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + string subject_id = 2 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +message ListSolutionEntitlementsResponse { + repeated SolutionEntitlement entitlements = 1; + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + +// Deliberately NOT here: a revision or fingerprint field. A consumer that caches +// a projection built from these rows keys it on a digest of the rows it actually +// received, which is exact by construction. A revision computed server-side would +// have to describe either one page or a set larger than the caller read, and the +// paginated case makes the two disagree. The ordering is deterministic (keyset on +// installation id), so a consumer's own digest is stable across reads that return +// the same entitlements. diff --git a/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_registry.proto b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_registry.proto index 3cc97e45b..08933023d 100644 --- a/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_registry.proto +++ b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_registry.proto @@ -2,65 +2,47 @@ syntax = "proto3"; package saas.accounts.v1; -import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; -// ============================================================================ -// Durable solution registry (issue #534) -// -// A solution is an independently deployed component the host has no build-time -// knowledge of. It self-registers two halves at runtime: a frontend half (the -// Module-Federation manifest the host renders and navigates to) and a backend -// half (the upstream the gateway proxies /solutions//* to). Both used to be -// process-local maps, so a restart lost them, a registration behind a load -// balancer reached one replica only, and one half could be live while the other -// was missing. -// -// These messages live in their own file, with deliberately minimal imports, so -// the auth-gateway can generate the same types from the same source proto and -// call SolutionRegistryService (declared in solution_registry_service.proto) -// over the internal listener. Sharing the generated definition rather than -// restating the wire shape is what keeps the two sides from drifting. -// -// Two facts are deliberately distinct. Durable installation intent is the -// record existing and not being tombstoned; ephemeral endpoint liveness is the -// per-half lease, renewed by whoever registered that half. A lapsed lease -// suspends routing without discarding the registration, and the derived status -// says which of the two is at fault. -// ============================================================================ +// Read-only projection of the host's declared solution presence. The gateway +// shares these messages and reads the accounts internal listener. -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. enum SolutionRegistrationStatus { SOLUTION_REGISTRATION_STATUS_UNSPECIFIED = 0; - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SOLUTION_REGISTRATION_STATUS_PENDING = 2; - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; + // Was `SOLUTION_REGISTRATION_STATUS_EXPIRED`: both halves registered but a + // lease had lapsed. Presence is delivered, never heartbeat-asserted, so there + // is no lease to lapse and no status to derive from one. Reserved rather than + // reused: an old reader decoding 3 would read "expired" out of whatever + // meaning a later value gave the number. + reserved 3; + reserved "SOLUTION_REGISTRATION_STATUS_EXPIRED"; // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE = 4; - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; } // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. message SolutionFrontendBinding { int64 revision = 1; string manifest = 2; string contract_version = 3; - google.protobuf.Timestamp lease_expires_at = 4; + // Was `google.protobuf.Timestamp lease_expires_at`: the renewal path is + // deleted, so a column nothing renews was legacy. Reserved, not reused — a + // released number never takes another name or type. + reserved 4; + reserved "lease_expires_at"; } // SolutionBackendBinding is the stored backend half: the upstream the gateway @@ -70,7 +52,10 @@ message SolutionBackendBinding { string upstream = 2; string service_alias = 3; string contract_version = 4; - google.protobuf.Timestamp lease_expires_at = 5; + // Was `google.protobuf.Timestamp lease_expires_at`: see + // SolutionFrontendBinding above. Reserved, not reused. + reserved 5; + reserved "lease_expires_at"; } // SolutionRegistration is the canonical record. revision is drawn from one @@ -78,8 +63,7 @@ message SolutionBackendBinding { // consumer can order two snapshots without a wall clock. message SolutionRegistration { string solution_id = 1; - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. string publisher = 2; int64 revision = 3; SolutionRegistrationStatus status = 4; @@ -87,91 +71,20 @@ message SolutionRegistration { optional SolutionBackendBinding backend = 6; google.protobuf.Timestamp updated_at = 7; optional google.protobuf.Timestamp tombstoned_at = 8; - // The seed a solution's runtime boundary is derived from (issue #1015). + // Was `string runtime_boundary`: the opaque id every Work Context minted for + // this solution is sealed under. It is NOT a field a readable record carries + // — a boundary returned on a readable record is a capability leaking as a + // label — so it is deleted from the wire rather than renamed, and the host + // seals it into the capability instead. // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - string runtime_boundary = 9; -} - -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -message SolutionFrontendRegistration { - string manifest = 1 [(buf.validate.field).string = { - min_len: 2 - max_len: 262144 - }]; - string contract_version = 2 [(buf.validate.field).string.max_len = 128]; -} - -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -message SolutionBackendRegistration { - string upstream = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 2048 - uri: true - }]; - string service_alias = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - string contract_version = 3 [(buf.validate.field).string.max_len = 128]; -} - -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -message PutSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - pattern: "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$" - }]; - string publisher = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 256 - }]; - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - optional int64 expected_revision = 3 [(buf.validate.field).int64.gt = 0]; - // Requested liveness window for this half. The registrant renews within it. - uint32 lease_seconds = 4 [(buf.validate.field).uint32 = { - gte: 30 - lte: 3600 - }]; - oneof half { - option (buf.validate.oneof).required = true; - SolutionFrontendRegistration frontend = 5; - SolutionBackendRegistration backend = 6; - } -} - -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -message DeleteSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - optional int64 expected_revision = 2 [(buf.validate.field).int64.gt = 0]; + // Reserved rather than reused. A released field number never takes another + // name or type: a consumer still on the old schema reading 9 would decode a + // message where it expected a string. `declared` therefore took 10, which + // cost a digest move and is the price of not corrupting an old reader. + reserved 9; + reserved "runtime_boundary"; + // The binding and immutable target that declared this record. + optional SolutionDeclaredBinding declared = 10; } // ListSolutionRegistrationsRequest reads the whole registry. There is one @@ -189,3 +102,127 @@ message ListSolutionRegistrationsResponse { // report the same registry_revision have converged on the same state. int64 registry_revision = 2; } + +// ============================================================================ +// Declared solution presence (issue #952) +// +// A SolutionHostBinding is the document delivery hands this host: which solution +// runs here, at which generation. The host reconciles it into the registration +// record above. These messages are the read surface, and they keep the three +// facts apart on purpose — an operator's first question is which of them is +// wrong. +// ============================================================================ + +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +enum SolutionDeclaredKind { + SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SOLUTION_DECLARED_KIND_SOLUTION = 1; + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SOLUTION_DECLARED_KIND_MODULE = 2; +} + +// SolutionDeclaredBinding identifies the applied declaration and target. +message SolutionDeclaredBinding { + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + string binding_id = 1; + // The generation of that binding the host applied into this record. + uint64 generation = 2; + // publisher/name@version of the applied generation. + string release = 3; + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. + // + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + string target_id = 4; + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + SolutionDeclaredKind kind = 5; +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +message SolutionHostBindingGeneration { + uint64 generation = 1; + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + string digest = 2; + string document = 3; + google.protobuf.Timestamp at = 4; +} + +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +message SolutionHostBindingAppliedGeneration { + SolutionHostBindingGeneration generation = 1; + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + bool removed = 2; + // The route aliases the applied generation holds. A tombstone holds none. + repeated string routes = 3; + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + string solution_id = 4; + string release = 5; +} + +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +message SolutionHostBindingState { + string binding_id = 1; + string host_coordinate = 2; + string host_component = 3; + // The newest generation delivery has shown this host, whether or not it + // passed. + optional SolutionHostBindingGeneration desired = 4; + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + optional SolutionHostBindingAppliedGeneration applied = 5; + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + uint64 pending_generation = 6; + // Why the pending generation was not applied. Empty when nothing is pending. + string pending_reason = 7; + optional google.protobuf.Timestamp pending_since = 8; + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + optional SolutionRegistration registration = 9; + google.protobuf.Timestamp updated_at = 10; +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +message ListSolutionHostBindingsRequest {} + +message ListSolutionHostBindingsResponse { + repeated SolutionHostBindingState bindings = 1; +} diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 4792fb449..6b4b6f074 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:54cb486f38f377a71fec1a6bbfc8f05fcb86a00054dd00cf1dd7b76a38aaac29", + "digest": "sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c", "services": [ { "name": "APIKeyService", @@ -173,6 +173,8 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -375,13 +377,19 @@ "/saas.accounts.v1.SSOAdminService/StartSetup" ] }, + { + "name": "SolutionEntitlementService", + "fullName": "saas.accounts.v1.SolutionEntitlementService", + "procedures": [ + "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" + ] + }, { "name": "SolutionRegistryService", "fullName": "saas.accounts.v1.SolutionRegistryService", "procedures": [ - "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", - "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", - "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" + "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", + "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" ] }, { @@ -481,7 +489,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:54cb486f38f377a71fec1a6bbfc8f05fcb86a00054dd00cf1dd7b76a38aaac29", + "digest": "sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c", "services": [ { "name": "APIKeyService", @@ -644,6 +652,8 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -846,13 +856,19 @@ "/saas.accounts.v1.SSOAdminService/StartSetup" ] }, + { + "name": "SolutionEntitlementService", + "fullName": "saas.accounts.v1.SolutionEntitlementService", + "procedures": [ + "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" + ] + }, { "name": "SolutionRegistryService", "fullName": "saas.accounts.v1.SolutionRegistryService", "procedures": [ - "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", - "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", - "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" + "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", + "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" ] }, { @@ -952,7 +968,7 @@ "kind": "protobuf", "package": "saas.gateway.auth.v1", "path": "contracts/api/auth-gateway/grpc/contract.binpb", - "digest": "sha256:b18ae2e95b39482587abaed305395b2bca29e118710639fa1d0535acd018aaf1", + "digest": "sha256:0c2b91ff00eb67f00dc415914d68b3f699119c71e010a13662057056fa10314d", "services": [ { "name": "AuthGatewayService", diff --git a/module/contracts/behavior.codefly.json b/module/contracts/behavior.codefly.json index 2141e38f5..78e79ff21 100644 --- a/module/contracts/behavior.codefly.json +++ b/module/contracts/behavior.codefly.json @@ -8,13 +8,13 @@ "documentation": "SOLUTION_REGISTRATION.md", "contract": { "group": "federation", - "purpose": "Declares which publishers may federate a REST prefix, obtain a module Work Context, or register a solution. The group is declared on its own so a composition can override it without restating unrelated keys.", + "purpose": "Declares module identity digests and the registration mint digests pending removal with the ModuleCapabilitiesService handlers.", "keys": [ { "name": "MODULE_REGISTRATION_SECRETS", "form": "comma-separated prefix:sha256hex", "digestOf": "the module's own registration secret", - "empty": "no composed module may federate a REST prefix", + "empty": "no module registration credential may be minted", "reread": "startup" }, { @@ -28,7 +28,7 @@ "name": "SOLUTION_REGISTRATION_SECRETS", "form": "comma-separated id:sha256hex", "digestOf": "the solution's own registration secret", - "empty": "no solution may register", + "empty": "no solution registration credential may be minted", "reread": "every exchange" } ], @@ -46,7 +46,7 @@ "requiredByAll": true, "documentation": "services/auth-gateway/AGENTS.md", "contract": { - "scope": "Every request proxied to a catalog route, a registered solution upstream, or a federated module prefix.", + "scope": "Every request proxied to a catalog route or a declared solution upstream.", "check": "The gateway runs an ext_authz Check and proxies only on success. A registered target never changes which check runs.", "inboundHeaders": "Every canonical identity header is stripped from the inbound request before the verified principal is stamped, so a caller cannot present one on a route that stamps a subset.", "stampedHeaders": [ @@ -91,19 +91,6 @@ "behavior/configuration.federation-secrets" ], "contract": { - "exchange": { - "method": "POST", - "path": "/solutions/_registration-token", - "on": "auth-gateway", - "body": { - "id": "" - }, - "headers": { - "X-Codefly-Internal-Token": "cluster-internal perimeter check, not the authorization", - "X-Codefly-Solution-Secret": "the solution's own registration secret" - }, - "requestBodyLimitBytes": 4096 - }, "broker": { "procedure": "saas.accounts.v1.ModuleCapabilitiesService/MintSolutionRegistration", "exposure": "EXPOSURE_INTERNAL", @@ -122,114 +109,11 @@ "ttlSeconds": 300 }, "rules": [ - "The credential names one solution id and one publisher, so a holder can neither claim nor re-point another solution.", - "The declaration is read on every exchange: authorizing a publisher takes effect on its next exchange, and withdrawing one stops its next renewal.", - "The token is fetched per registration attempt and not cached across a restart.", - "The gateway mints nothing itself and adds no authority of its own." + "The internal mint remains pending removal with the ModuleCapabilitiesService handlers. No HTTP registration endpoint consumes its credential.", + "The credential is bound to one solution id and publisher." ] } }, - { - "id": "registration.solution.gateway-upstream", - "requiredByAll": true, - "documentation": "SOLUTION_REGISTRATION.md", - "dependencies": [ - "behavior/registration.solution.credential" - ], - "contract": { - "register": { - "method": "POST", - "path": "/solutions/_register", - "body": { - "id": "", - "upstream": "" - }, - "credentialHeader": "X-Codefly-Solution-Registration", - "withdraw": "DELETE on the same path" - }, - "verification": [ - "Ed25519 signature against the published JWKS, alg-locked to EdDSA", - "issuer, solution-registration audience, and expiry with skew leeway", - "the solution claim equals the id in the request, or 403", - "jti burned on use, so a captured credential cannot be replayed" - ], - "publisherBinding": { - "rule": "The first registration binds the id to the credential's subject. A write from a different subject is refused with 409.", - "move": "The same publisher may move its own upstream endpoint.", - "delete": "Owner-bound. A non-owner's delete is indistinguishable from an unknown id.", - "preContract": "A registration stored before publisher binding carried the bare id as its publisher. The upgrade rewrites that one pre-contract default to solution:, so the solution's own credential keeps renewing its record. A row whose publisher was self-asserted as anything else was never authenticated and is left as it is. Every registration now carries a non-empty verified publisher." - }, - "revision": "The gateway supplies the compare-and-swap revision it last saw. A registrant never supplies one.", - "requestBodyLimitBytes": 262144, - "identity": "The id is one catalog-identity segment, so reserved underscore-prefixed sub-paths cannot be shadowed.", - "upstream": { - "allowed": "composition-local only: loopback and private ranges, mesh short names and cluster suffixes", - "refused": "globally routable, link-local, and cloud-metadata addresses", - "dialTime": "The resolved address is re-checked at dial time, so a mesh-looking name whose answer later points off-mesh never receives a forwarded bearer.", - "loopback": "an explicit development exception" - }, - "proxy": { - "path": "/solutions/{id}/...", - "check": "the same ext_authz Check and identity-header discipline as catalog routes", - "unauthenticated": { - "subPaths": [ - "/assets", - "/.well-known" - ], - "methods": [ - "GET", - "HEAD" - ] - } - }, - "registry": { - "method": "GET", - "path": "/solutions/_registry", - "returns": [ - "id", - "publisher", - "revision", - "status" - ], - "statuses": [ - "active", - "pending", - "expired", - "incompatible", - "tombstoned" - ], - "neverReturns": "an upstream" - } - } - }, - { - "id": "registration.solution.frontend-remote", - "documentation": "SOLUTION_REGISTRATION.md", - "dependencies": [ - "behavior/registration.solution.credential", - "behavior/federation.solution.runtime-compatibility" - ], - "contract": { - "register": { - "method": "POST", - "path": "/api/solutions/register", - "body": "the solution manifest", - "credentialHeader": "X-Codefly-Solution-Registration", - "withdraw": "DELETE on the same path" - }, - "independence": "The frontend verifies the same credential independently of the gateway. Neither half trusts the other's decision, and neither holds a secret every registrant shares.", - "verification": [ - "Ed25519 signature against the published JWKS, alg-locked to EdDSA", - "issuer, solution-registration audience, and expiry with skew leeway", - "the id comes from the manifest and must equal the solution claim, or 403", - "the first registration binds the id to the credential's subject; a different subject is refused with 409", - "jti burned on use" - ], - "trustTier": "A registered remote is loaded as a Module-Federation remote and executes as JavaScript in the host origin, receiving the viewer's capabilities. Registering a solution is approximately equivalent to deploying first-party code. Module-Federation singleton sharing, CSP origin inclusion, and row-level security are not sandboxes.", - "page": "/s/{id}", - "manifestBound": "The route applies no bound of its own. The manifest is bounded at 262144 bytes where the gateway accepts it, which is also the registry's limit." - } - }, { "id": "federation.solution.runtime-compatibility", "documentation": "SOLUTION_REGISTRATION.md", @@ -299,75 +183,34 @@ "versions": "The exact published version of each key is a release fact of the host build, carried by the module package rather than restated here." }, "refusal": { - "whole": "A refused registration is refused whole.", - "fallback": "If the id already had a valid registration, that registration keeps serving. The last known valid one is never replaced by a rejected update.", - "response": { - "status": 409, - "body": { - "error": "incompatible_runtime", - "reasons": [ - "..." - ] - } - }, - "noFallback": "If the id has no valid registration to fall back on, its page renders a temporarily-unavailable panel rather than a 404, and the technical cause stays in the server log." + "whole": "An incompatible stored manifest is omitted from every frontend projection.", + "unavailable": "A failed registry refresh drops the cached snapshot and the read fails closed. No stale manifest is served." } } }, { - "id": "registration.module.rest-federation", + "id": "registration.module.credential", "documentation": "services/auth-gateway/AGENTS.md", "dependencies": [ "api/accounts/connect#/saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration", "behavior/configuration.federation-secrets" ], "contract": { - "purpose": "A composed module that serves its own /v1//* surface registers it, and the gateway proxies that prefix once the generated catalog has no match.", - "handshake": [ - { - "step": 1, - "method": "POST", - "path": "/modules/_registration-token", - "headers": { - "X-Codefly-Internal-Token": "cluster-internal perimeter check", - "X-Codefly-Module-Secret": "the module's own registration secret" - }, - "body": { - "prefix": "" - } - }, - { - "step": 2, - "broker": { - "procedure": "saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration", - "exposure": "EXPOSURE_INTERNAL", - "authorizedAgainst": "MODULE_REGISTRATION_SECRETS entry for that exact prefix", - "audit": "module.registration_minted" - }, - "token": { - "algorithm": "EdDSA", - "audience": "module-registration", - "subject": "module:", - "ttlSeconds": 300 - } - }, - { - "step": 3, - "method": "POST", - "path": "/modules/_register", - "credentialHeader": "X-Codefly-Module-Registration", - "body": { - "prefix": "", - "upstream": "" - } - } - ], + "broker": { + "procedure": "saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration", + "exposure": "EXPOSURE_INTERNAL", + "authorizedAgainst": "MODULE_REGISTRATION_SECRETS entry for that exact prefix", + "audit": "module.registration_minted" + }, + "token": { + "algorithm": "EdDSA", + "audience": "module-registration", + "subject": "module:", + "ttlSeconds": 300 + }, "rules": [ - "The credential is prefix-bound, so a module holding the credential for one prefix cannot claim another.", - "The shared cluster-internal token alone does not authorize registration.", - "The token is short-lived and fetched per registration attempt, not cached across a gateway restart.", - "Registration only adds a proxy target. Every proxied /v1//* request still runs the full ext_authz check.", - "The prefix declaration is not re-read per exchange: a composed module cannot change its declaration without a new deployment." + "The internal mint remains pending removal with the ModuleCapabilitiesService handlers. No HTTP registration endpoint consumes its credential.", + "The credential is bound to one module prefix." ] } }, @@ -486,6 +329,30 @@ }, "limitation": "Uninstalling in one organization revokes that organization's agent authority and does not remove the solution's nav entry, page, or gateway route, for that organization or any other. A registered solution's UI and API surface is visible to every tenant of the deployment. Per-tenant admission before route or page exposure is an additional gate that is not implemented here." } + }, + { + "id": "presence.solution.declared-projection", + "requiredByAll": true, + "documentation": "SOLUTION_REGISTRATION.md", + "dependencies": [ + "api/accounts/connect#/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", + "behavior/federation.solution.runtime-compatibility" + ], + "contract": { + "authority": "Delivery declares presence through SolutionHostBinding; accounts reconciles the durable registry.", + "gatewaySnapshot": { + "method": "GET", + "path": "/solutions/_registry", + "authorization": "cluster-internal credential", + "backendUpstream": "never projected" + }, + "frontendNavigation": { + "method": "GET", + "path": "/api/solutions", + "authorization": "viewer entitlements joined by immutable target id" + }, + "routing": "Catalog routes own module REST paths. Solution routes resolve the declared target and upstream together before per-viewer installation admission. Unknown routes return 404." + } } ] } diff --git a/module/deployment/AGENTS.md b/module/deployment/AGENTS.md index 4b73a617b..29e2df1c4 100644 --- a/module/deployment/AGENTS.md +++ b/module/deployment/AGENTS.md @@ -54,3 +54,36 @@ a committed path or version: what runs. - **Released** means tagged here, after which the composing workspace moves its `version` and re-renders. A dev deployment is a trial, never the fix. + +## `exposure` on a public endpoint, and why both values are decisions + +Core v0.14.0 requires `exposure` on any endpoint declaring `visibility: public` +and refuses a manifest that omits it: *"a public endpoint never omits it, so a +manifest written when public meant an address fails here instead of losing it"*. +Two endpoints in this module are public — `frontend/http` and `marketing/http` — +and both carry `exposure: public`. + +**It goes on the SERVICE manifest, never on an interface entry.** +`InterfaceEndpoint` has no `Exposure` field and refuses an unknown key by presence, +so writing it there is a hard load error. The rule v0.14.0 enforces is a *pairing*: +an endpoint declaring `exposure: public` must be exported at `visibility: public` +by its interface entry. That pairing already held here, so the interface needs no +change. + +**Why `public` is the safe value and `none` is the risky one.** v0.13.0's +`visibility == public` is precisely the condition v0.14.0 moves to `exposure`, and +`frontend/http` has been `visibility: public` all along — so the current render +already shows what the condition produces: a plain ClusterIP `Service`, a staging +overlay of configmap/external-secret/kustomization only, and **zero** `Ingress`, +`HTTPRoute`, `LoadBalancer` or `NodePort` objects anywhere under `deployments/`. +Staging renders nothing outward because the staging environment declares no +`ingress:` block; `local` declares two. `exposure: none` would be the change in +behaviour — it could take the local ingress binding away. + +**One value, and no mechanism to condition it.** `exposure` lives in the module's +`service.codefly.yaml`, read identically for every environment; a composition has +no key with which to override a module's endpoint declaration per environment. + +The key is **dropped** by core v0.8.1 and v0.13.0, so it is inert on any CLI older +than the v0.14.0 generation and mandatory on that one. See AGENTS.md, "Building, +testing, and CI", for the CI pin that reads it. diff --git a/module/deployment/generated/asyncapi.json b/module/deployment/generated/asyncapi.json index fefd01ba5..f01e534a3 100644 --- a/module/deployment/generated/asyncapi.json +++ b/module/deployment/generated/asyncapi.json @@ -1615,6 +1615,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.solution.host_binding_applied": { + "address": "saas.solution.host_binding_applied", + "title": "saas.solution.host_binding_applied", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.solution.host_binding_applied" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.solution.registration_deleted": { "address": "saas.solution.registration_deleted", "title": "saas.solution.registration_deleted", @@ -1637,17 +1648,6 @@ "x-visibility": "external", "x-retention": "30d" }, - "saas.solution.registration_updated": { - "address": "saas.solution.registration_updated", - "title": "saas.solution.registration_updated", - "messages": { - "envelope": { - "$ref": "#/components/messages/saas.solution.registration_updated" - } - }, - "x-visibility": "external", - "x-retention": "30d" - }, "saas.sso.disabled": { "address": "saas.sso.disabled", "title": "saas.sso.disabled", @@ -3045,6 +3045,13 @@ }, "title": "saas publishes saas.settings.updated" }, + "send:saas.solution.host_binding_applied": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.solution.host_binding_applied" + }, + "title": "saas publishes saas.solution.host_binding_applied" + }, "send:saas.solution.registration_deleted": { "action": "send", "channel": { @@ -3059,13 +3066,6 @@ }, "title": "saas publishes saas.solution.registration_minted" }, - "send:saas.solution.registration_updated": { - "action": "send", - "channel": { - "$ref": "#/channels/saas.solution.registration_updated" - }, - "title": "saas publishes saas.solution.registration_updated" - }, "send:saas.sso.disabled": { "action": "send", "channel": { @@ -4322,6 +4322,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.solution.host_binding_applied": { + "name": "saas.solution.host_binding_applied", + "title": "saas.solution.host_binding_applied", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.solution.registration_deleted": { "name": "saas.solution.registration_deleted", "title": "saas.solution.registration_deleted", @@ -4336,13 +4343,6 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, - "saas.solution.registration_updated": { - "name": "saas.solution.registration_updated", - "title": "saas.solution.registration_updated", - "payload": { - "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" - } - }, "saas.sso.disabled": { "name": "saas.sso.disabled", "title": "saas.sso.disabled", diff --git a/module/deployment/generated/communication.md b/module/deployment/generated/communication.md index 7ac48fac5..3d9d20ba9 100644 --- a/module/deployment/generated/communication.md +++ b/module/deployment/generated/communication.md @@ -1176,7 +1176,7 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ -## saas.solution.registration_deleted +## saas.solution.host_binding_applied - **Publisher:** saas - **Visibility:** external @@ -1184,7 +1184,7 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ -## saas.solution.registration_minted +## saas.solution.registration_deleted - **Publisher:** saas - **Visibility:** external @@ -1192,7 +1192,7 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ -## saas.solution.registration_updated +## saas.solution.registration_minted - **Publisher:** saas - **Visibility:** external diff --git a/module/deployment/generated/event-catalog.json b/module/deployment/generated/event-catalog.json index 392dbc816..e53999b71 100644 --- a/module/deployment/generated/event-catalog.json +++ b/module/deployment/generated/event-catalog.json @@ -14605,7 +14605,7 @@ ] }, { - "type": "saas.solution.registration_deleted", + "type": "saas.solution.host_binding_applied", "namespace": "saas", "schema": "saas/events/v1/events.proto#EventEnvelope", "major": 1, @@ -14705,7 +14705,7 @@ ] }, { - "type": "saas.solution.registration_minted", + "type": "saas.solution.registration_deleted", "namespace": "saas", "schema": "saas/events/v1/events.proto#EventEnvelope", "major": 1, @@ -14805,7 +14805,7 @@ ] }, { - "type": "saas.solution.registration_updated", + "type": "saas.solution.registration_minted", "namespace": "saas", "schema": "saas/events/v1/events.proto#EventEnvelope", "major": 1, diff --git a/module/deployment/generated/service-topology.json b/module/deployment/generated/service-topology.json index b40e3cafd..f0d29f775 100644 --- a/module/deployment/generated/service-topology.json +++ b/module/deployment/generated/service-topology.json @@ -144,13 +144,24 @@ } ] }, + { + "name": "policy-log", + "endpoints": [ + { + "name": "grpc", + "api": "CODEFLY_API_GRPC", + "visibility": "ENDPOINT_VISIBILITY_PRIVATE", + "port": 9090 + } + ] + }, { "name": "store", "endpoints": [ { "name": "tcp", "api": "CODEFLY_API_TCP", - "visibility": "ENDPOINT_VISIBILITY_EXTERNAL", + "visibility": "ENDPOINT_VISIBILITY_PRIVATE", "port": 5432 } ] @@ -229,6 +240,12 @@ 443 ] }, + { + "service": "policy-log", + "ports": [ + 443 + ] + }, { "service": "telemetry", "ports": [ diff --git a/module/module.codefly.yaml b/module/module.codefly.yaml index 492c49e95..cf4c64ec5 100644 --- a/module/module.codefly.yaml +++ b/module/module.codefly.yaml @@ -2,24 +2,27 @@ kind: module name: saas-starter description: SaaS foundation — auth, multi-tenancy, generated RPC policy, RBAC, impersonation, audit service-entry: frontend +# An interface entry EXPORTS an endpoint; it does not grant reach to it. The +# `allow-modules` wildcards that used to sit on these four entries are deleted, +# because core v0.14.0 refuses the key here by name: "an allow-list is derived +# from the consumers' declared service dependencies, never written by the module +# it would grant — a module asks for what it consumes, and the target names +# nobody". A host writing `["*"]` was answering a question that is not its to +# answer, and the wildcard made it unanswerable besides. interface: endpoints: - service: accounts endpoint: authority visibility: internal - allow-modules: ["*"] - service: accounts endpoint: connect visibility: internal - allow-modules: ["*"] - service: auth-gateway endpoint: grpc visibility: internal - allow-modules: ["*"] - service: auth-gateway endpoint: rest visibility: internal - allow-modules: ["*"] - service: frontend endpoint: http visibility: public @@ -32,6 +35,7 @@ services: - name: cache - name: frontend - name: marketing + - name: policy-log - name: store - name: telemetry - name: vault diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index cb788745d..39fd2c478 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:54cb486f38f377a71fec1a6bbfc8f05fcb86a00054dd00cf1dd7b76a38aaac29 + digest: sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:54cb486f38f377a71fec1a6bbfc8f05fcb86a00054dd00cf1dd7b76a38aaac29 + digest: sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c - name: auth-gateway endpoints: [grpc, rest] api-contracts: @@ -31,13 +31,15 @@ services: kind: protobuf package: saas.gateway.auth.v1 path: contracts/api/auth-gateway/grpc/contract.binpb - digest: sha256:b18ae2e95b39482587abaed305395b2bca29e118710639fa1d0535acd018aaf1 + digest: sha256:0c2b91ff00eb67f00dc415914d68b3f699119c71e010a13662057056fa10314d - name: cache endpoints: [read, write] - name: frontend endpoints: [http] - name: marketing endpoints: [http] + - name: policy-log + endpoints: [grpc] - name: store endpoints: [tcp] - name: telemetry diff --git a/module/services/README.md b/module/services/README.md index 09ba17c49..84e374b62 100644 --- a/module/services/README.md +++ b/module/services/README.md @@ -1,9 +1,17 @@ # Module services -The module contains eight first-class Codefly services. `marketing` and -`frontend` are separate Next.js applications: marketing owns public -apex/`www` content, while frontend remains the authenticated product behind -`auth-gateway`. +`module/module.codefly.yaml` is the inventory: every first-class Codefly +service this module builds is listed there, and that list is what the package +manifest is held to. No count is written here — a number in prose is one two +branches each bump to the same value and merge into a total that is silently +wrong, and nothing enforces it. + +`marketing` and `frontend` are separate Next.js applications: marketing owns +public apex/`www` content, while frontend remains the authenticated product +behind `auth-gateway`. `policy-log` is the independent signed witness for +authority changes; it is private to this module, has no public endpoint, and +belongs to the warehouse's trust domain rather than to the database whose +narrowings it witnesses — see `policy-log/README.md`. Run the complete local dependency graph from `module/`: diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index da538ebef..8ddba53ad 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -16,122 +16,123 @@ spelling that older docs and the `generated-pins-gate` message still show no longer works. The full procedure and why are in [../../REST_SURFACE.md](../../REST_SURFACE.md#regeneration). -Who registers a composed module's REST prefix is in -[../auth-gateway/AGENTS.md](../auth-gateway/AGENTS.md#composed-module-rest-federation): -the consuming backend, holding the prefix's registration secret. The consumed -module holds only its identity secret, which is what the Work Context -exchanges below authenticate. - -## The solution registry is one durable record - -Both halves of a solution registration — frontend remote and gateway upstream — -are **one durable record**, not two process-local maps. -`solution_registrations` (migration `126_solution_registrations`) holds the -solution identity, its publisher, the frontend and backend halves, a -registry-wide `revision`, and a per-half lease. A registration therefore -survives a restart and reaches every replica, and is only served when it is -**whole**: a solution that registered its page but not its backend is a durable -`pending` record, deliberately absent from the navigation. - -- Writes are **compare-and-swap on the revision**, so a stale publisher cannot - overwrite newer state. -- A frontend-half write that changes the manifest also **admits the audit event - types its dashboard graph declares** (events carrying `fields`), in the same - transaction (`pkg/business/solution_audit_events.go`). An admitted type is an - `audit_event_types` row owned by `solution:`. A solution admits types - only into the namespaces its own `MODULE_PRINCIPALS` entry (keyed by its - solution id) binds — no entry, no admission — so the registration credential - alone claims nothing. A namespace belongs to one producer (one the composed - event catalog publishes domain events under is already held), a - re-declaration may only add fields, and a refusal rolls the whole write back. - Ownership follows the binding: the operator releases a namespace by removing - it from the holder's entry and binding it to another solution, whose next - admission takes every type in it over, recorded as - `audit_namespaces_taken_over` on `saas.solution.registration_updated`. - Every path that reads a type's schema — the version stamp, payload checks, - the category label, and PII redaction on webhooks, the export feed and - downloads — resolves it through one lookup (`business.AuditEventResolver`), - which reads a declared type's row, so a declared field marked `pii` is - stripped like a catalog one and a declared type is never dead-lettered as - unregistered. `ModuleEmitAuditEvent` accepts a declared type only when the - `solution` scope names its owner and the caller's `MODULE_PRINCIPALS` grant - lists its namespace. A declared type also carries a **visibility** — `tenant` - by default, or `external` — and only an `external` one is ever delivered to a - tenant's outbound webhook endpoint. It takes two keys: the producer declares - it (manifest `dashboard.events[].visibility`, or - `ModuleAuditEventTypeDeclaration.visibility`) and the operator grants the - namespace external delivery (`external_namespaces`, a subset of `namespaces`, - refused at boot if it is not). Visibility is fixed at admission — a - re-declaration that changes it is refused — because narrowing would silently - stop deliveries to endpoints already subscribed and widening would start - sending out facts under a name a tenant subscribed to when it meant something - else. -- A composed **module** with no frontend half declares its own audit event - types through `ModuleCapabilitiesService.DeclareAuditEventTypes` - (`pkg/business/module_audit_declarations.go`): the same validator - (`ValidateAuditEventTypeDeclarations`) and the same admission, under the - same binding. `prefix` must be the caller's own (the principal derived from - it), the types are owned as `solution:`, and its emissions name that - prefix as `solution`. Re-declaring what is admitted writes and records - nothing, so a module may declare on every start; a change is recorded as - `saas.module.audit_types_declared`. -- A deregistration leaves a **tombstone**, so a retiring deployment's delayed - heartbeat cannot resurrect it. -- accounts serves this as `SolutionRegistryService` on the **internal listener**; - the auth-gateway is its **only** client and brokers the frontend's half, - exactly as it brokers module registration. -- Both surfaces hold a short-lived cache rebuilt from that snapshot, so - convergence after any write is bounded: the gateway reconciles about every 10s - plus an on-demand refresh on a cache miss, the frontend holds a 5s snapshot TTL. - -## The record carries the solution's runtime boundary seed +A composed module's REST routes belong to the gateway's generated and explicit +catalogs. Its identity secret authenticates the Work Context exchanges below. + +The principal directory lists stored identities only. Declared module authority +is read by the capability checks and does not create synthetic directory rows. + +## Declared solution presence and the registry + +`solution_host_bindings` stores desired and applied `SolutionHostBinding` +generations and pending reasons. Core owns document admission; each apply +re-decides under the binding row lock. A refused or unreadable delivery does not +withdraw the last applied generation. Withdrawal requires a tombstone generation. + +`solution_registrations` is the durable read projection, keyed by the route alias. +Every row names its declared binding, generation, release and immutable target. +A binding ID identifies a deployment instance, while an alias identifies a route; +consent names the target and cannot follow an alias to a replacement instance. +Only observations for the same declared target may survive a re-declaration. + +The internal `SolutionRegistryService` exposes the registry and host-binding +reads. The gateway reads the snapshot and brokers it to the frontend. Both cache +briefly: gateway reconciliation is approximately 10 seconds with a refresh on +cache miss; frontend snapshots expire after 5 seconds and a failed refresh +reports unavailable. An incomplete record stays pending and is not served. + +Migration 26 destructively removes runtime-only state and makes declaration +ownership mandatory. Its from-zero proof is `TestMigration25FromZero` in the +store module; `TestMigration22FromZero` separately proves immutable installation +targets and withdrawal serialization using a freshly created package database. + +Audit type admission remains available through +`ModuleCapabilitiesService.DeclareAuditEventTypes`. Its validator and namespace +admission use declared module authority. Namespace ownership, additive schema +changes, immutable visibility and the operator's `external_namespaces` grant +remain enforced. `ModuleEmitAuditEvent` can emit only a type owned by the caller's +solution scope and an authorized namespace. `business.AuditEventResolver` resolves +stored schemas for payload validation and PII redaction. + +The principal directory lists stored identities. The `MODULE_PRINCIPALS` map +below supplies declared authority to capability checks only. + +See [../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md#7-declared-presence) +for the trust model, field ownership and delivery configuration. + +## The solution's runtime boundary is derived from its delivered binding A runtime task is reachable only under the boundary of the Work Context that -admitted it — the context's `task_id`. -`solution_registrations.runtime_boundary` (migration -`17_solution_runtime_boundary`) is the **seed** that boundary is derived from, -so a run a page admits stays reachable across the mints of one session rather -than only under the one context that admitted it. - -**The boundary is derived per organization**, not stored: a UUIDv5 of the seed -and the org id (`business.SolutionRuntimeBoundary`). A run is filed under -(tenant, boundary), so a single per-solution value would make every tenant of a -solution share one. The seed never leaves this host. - -**The host assigns it and nothing else can.** The column's `gen_random_uuid()` -default fires on insert; the registry's upsert deliberately omits the column -from its `ON CONFLICT … DO UPDATE`, and `RETURNING` makes the stored value the -one the caller gets back. A UNIQUE constraint keeps two registrations from -sharing a seed. So no request field reaches it, no write replaces it, and it -survives a tombstone — a reactivated registration keeps naming the runs it -already admitted. - -**No response carries a seed.** `solutionRegistrationProto` never sets the -field, on any path: not a listing, not a deregistration, not either half's own -write. Nothing above this service needs one, because accounts derives and seals -the boundary itself. `TestSolutionRegistrationResponsesCarryNoRuntimeBoundary` -holds every response to that. - -**The mint** reads the seed through +admitted it — the context's `task_id`. A solution's boundary is derived from the +**delivered presence binding** (`solution_registrations.declared_binding_id`), so +a run a page admits stays reachable across the mints of one session rather than +only under the one context that admitted it. + +**The binding id is the seed because it has the lifetime a boundary needs.** It +survives a withdrawal and a re-approval, and it is terminal with its tombstone. +The seed used to be `solution_registrations.runtime_boundary`, a +per-REGISTRATION `gen_random_uuid()` from migration +`17_solution_runtime_boundary` — the wrong lifetime twice over: a solution +withdrawn and registered afresh drew a new value and orphaned every run filed +under the old one, while a binding that never moved could have its value replaced +by a write it did not make. **Migration 28 drops that column and its UNIQUE +constraint**, and `business.SolutionRegistration` carries no boundary field: a +field no store can populate would read as "this solution has no boundary" +wherever it was found empty. + +**The boundary is derived per organization**, not stored: a UUIDv5 of a namespace +derived from the binding id, then of the org id +(`business.SolutionRuntimeBoundary`). A run is filed under (tenant, boundary), so +a single per-solution value would make every tenant of a solution share one. Two +steps, because a binding id is not a UUID — it may carry characters a path +segment may not, so `uuid.Parse` on it fails. `solutionBoundaryNamespace` is a +constant of this host and not a secret: unguessability comes from the per-org +derivation and from the boundary never appearing on a readable record. + +**Nothing above this service may choose one.** `declared_binding_id` is written +only from the delivered declaration, never from a registrant's request, so the +boundary moves when the host's own reconciliation moves it and at no other time. + +**No response carries a boundary.** `solutionRegistrationProto` sets no such +field on any path, and there is no field to set: field 9 is reserved on +`saas.accounts.v1.SolutionRegistration` and +`TestSolutionRegistrationResponsesCarryNoRuntimeBoundary` asserts over the +DESCRIPTOR, so reintroducing the field fails rather than only a value happening +to be empty. + +**The mint** reads the binding through `business.SolutionRuntimeBoundarySeedStore`, which also reports the publisher of record and whether the backend half is serving, and refuses a missing record and -a tombstone separately. Which solution is asking comes from -`auth.VerifiedSolution`, stamped from the `X-Codefly-Solution-Id` and -`X-Codefly-Solution-Publisher` the gateway proved from that solution's -registration credential (`../auth-gateway/AGENTS.md`); both are forwarded -identity headers, so they are stripped from any caller arriving without a valid -gateway token. Before deriving anything the mint checks the publisher equals the -credential's and the backend half is serving — the half that mints — and a -caller-supplied `task_id` is **refused**, not ignored. - -**And every other mint refuses a `task_id` that is somebody's boundary.** A +a tombstone separately. A row with no `declared_binding_id` answers the +tombstoned error: the cutover's own constraint makes an undeclared row a +withdrawn one. **Serving is delivered presence**, not an asserted lease — a +non-tombstoned declared row whose applied generation carries a backend half +(`backend_revision` and `backend_upstream`) — and the refusal names both columns. +Revocation reaches a capability through that per-mint read, under the existing +120 s cache bound: carrier deletion is not revocation, the applied tombstone is. + +Which solution is asking comes from `auth.VerifiedSolution`, and **nothing on the +wire sets it**: a request carrying `X-Codefly-Solution-Id` or +`X-Codefly-Solution-Publisher` behind a valid gateway token is **refused by +name** (`errSolutionAttestationNotDelivered`) rather than believed, because this +host has no delivered attestation to check such a claim against; without a +gateway token the headers are stripped. Either header alone is a claim. +`WithVerifiedSolution` is kept as that attestation's landing site and has no +non-test caller, which `TestNoProductionCodeSetsAVerifiedSolution` holds. So +solution-scoped minting does not work on this host until the attestation lands — +stated as the follow-up rather than left to be discovered. Before deriving +anything the mint checks the publisher equals the credential's and the backend +half is serving — the half that mints — and a caller-supplied `task_id` is +**refused**, not ignored. + +**And every other mint refuses a `task_id` that is any solution's boundary.** A boundary is not a secret a consumer keeps: a consuming module that serves durable runs reports, on a run it lets a person read, the Work Context task it was admitted under, so one read any caller is entitled to would otherwise hand them a stable value to name on an ordinary mint. `StartTask`'s ordinary path and `StartInstallationTask` both run `refuseRegisteredBoundary`, which compares the -caller's `task_id` against every stored seed and the boundary derived from each -for the organization named in the request — tombstones included, because a +caller's `task_id` against every stored binding id and the boundary derived from +each for the organization named in the request — tombstones included, because a removed solution's runs may still be executing. It fails **closed**: a registry that cannot answer refuses the mint, because the capability cannot then be shown not to be a solution's. Otherwise mints are unchanged: a request with no @@ -252,8 +253,8 @@ and each value case joins its `oneof` when that record exists, read in **The registration it returns withholds topology**: `catalogueRegistrationProto` blanks the frontend manifest, backend upstream and service alias, as every other -browser-facing projection of the registry does, and the runtime-boundary seed is -withheld as on every response. +browser-facing projection of the registry does; there is no runtime-boundary +field for it to withhold. ## The composed-module service principal @@ -261,7 +262,7 @@ A module consuming the module-facing capability surface (`ModuleCapabilitiesService`: job enqueue/claim, notify, approvals, audit, events, subject visibility) calls it as its own **service principal**, whose id is derived from the -same registration prefix (`business.ModulePrincipalID`) — nothing is +declared module prefix (`business.ModulePrincipalID`) — nothing is hand-authored as an opaque id. Its authority is declared in the `module-capabilities` group's @@ -296,9 +297,245 @@ once the capability exists. The response carries credentials never substitute for missing identity digests. - The tenant is **not requestable** — it is the one `MODULE_PRINCIPALS` declares for that principal, so a module cannot name a tenant by asking. -- The capability **seals identity and tenant only**: what the principal may do is - re-read from the declared grant on every call, so narrowing a grant takes - effect immediately rather than when the outstanding token expires. +- The capability's effective authority **is, in a running deployment today, the + LIVE half alone.** `min(sealed, live)` is the target — blocker decision **B1**, + which *reverses* what this file said before it — and it is **not yet reached**. + + **In a running deployment this mechanism enforces NOTHING on a module + capability, and that has to be said in those words.** The comparison against a + sealed ceiling is written and tested, but nothing populates the seal — the + module Work Context mint (`StartModuleTask`) passes only audience, tenant, + owner principal, task, session, actor chain and TTL, and `sdk-go`'s token + carries no installation id, producer epoch, binding revision, build + incarnation or image digest to put there. + + `checkAgainstSealed` returns on its first line when the seal is nil. So for + every real module credential the live read runs, fetches the epoch, and the + result is **discarded**: `AuthorizeModuleCapability` is `moduleGrant` plus a + database round trip whose answer nothing compares. The declared grant alone + binds, which is the behaviour that preceded this work. + + The consequences, stated rather than left to be derived: + + - **Uninstalling a solution does not revoke a module capability in flight.** + - **Advancing an organisation's or a principal's authority epoch does not + revoke one.** + - An earlier version of this paragraph said "the intersection has one operand". + That was still too generous: an intersection of one operand would at least be + bounded by the live read. Nothing is. + + Code that exists and a behaviour that holds are different facts, and a rules + file other agents read as a statement of what is in place must lead with the + second. This bullet has now been corrected three times, each time in the + direction of claiming less, which is itself the thing to notice about it. + + **What moved, and what the move does NOT yet reach.** Two of the pieces this + bullet described as absent now exist and run: + + - `ApprovedBuildReconciler` populates the approved-build view from the + delivered authority inbox, in the same reconcile pass as presence. Before + it, `MonotonicApprovedBuilds` was never written to, so every principal was + UNKNOWN and `ApprovedBuild` refused every one. + - `SetExecutionBinding` is called from `work.go`, with the Kubernetes client + that reviews a delivery carrier and that approved-build view. Before it, the + execution reviewer was nil on every running host and `BindExecution` + answered ErrExecutionUnbound for every caller — the mechanism was built, + guarded, unit-tested and unreachable. + + **The mint still does not call it, so the sentence above this one still + holds.** `StartModuleTask` passes audience, tenant, owner principal, task, + session, actor chain and TTL, and nothing else; the capability still carries + no seal, and `checkAgainstSealed` still returns on its first line for every + real module credential. `BindExecution` is now REACHABLE and is not REACHED. + + The reason is a contract, not an oversight: the mint authenticates with + `req.GetSecret()`, a shared secret, and a secret cannot be execution-bound in + principle — it says "I know the secret", never "I am this pod running this + image". Reaching the check means replacing that credential with a projected + service-account token on the request message, which is a proto change and a + consumer-visible break. Until that lands, every consequence listed above is + still a consequence. + + This bullet has now been wrong twice in opposite directions. It first asserted + the whole model in the present tense while none of it existed. It was then + corrected to "the live half is built, the sealed half is partly built" — true + of the code and still misleading about the deployment, because "partly built" + invites a reader to assume some sealing happens. None does. + + What follows separates what the code does from what it is still to do, and the + separation is load-bearing rather than cautious. + + **Both halves bind, and neither alone is enough.** Re-reading alone means a + widened grant, or a database restored to a broader state, retroactively widens + a credential already in flight. Sealing alone means a narrowing does not take + effect until the outstanding credential expires. So the effective authority is + the intersection, which is strictly stronger than either. + + **What is built.** Every module capability path resolves its authority through + `Service.AuthorizeModuleCapability`, which re-reads the live installation, its + revision and the principal's producer epoch in **one** statement — three + separate reads would let a revoke land between two of them and produce a + decision describing a state that never existed. The revocation predicate is a + conjunction and each term closes a different hole: + + - **installation freshness**, without which an uninstall-and-reinstall produces + a new installation an old credential would satisfy; + - **producer epoch**, without which a narrowing revokes only the credential + being held and the replacement is reminted with the old authority — so the + narrowing undoes itself after one credential lifetime; + - **binding revision**, for an operation context, resolved by + `Service.ExactOperationBinding`. + + A host that *cannot* re-read refuses every capability (`FailedPrecondition`) + rather than falling back to the declared ceiling. A read that *fails* is + `Unavailable` — neither a denial nor an allow, because a database blip is not + mass revocation and an allow would be the check not running. + + **The exact binding lookup exists; one call site still searches, and it is + named here rather than glossed.** `Service.ExactOperationBinding` resolves the + one binding a credential named, by id, and refuses a capability that names none + rather than falling back — an optional exact lookup is a search with extra + steps, so the fallback is the whole defect. + + But `moduleOperationContextStale` (`module_operation_context.go`) **still + searches**: it iterates `grant.OperationAudiences` and accepts if **any** + binding's `headless_scopes` contain the presented set. So a context minted + against a narrow binding is satisfied by any **wider** binding the principal + also holds, and narrowing one binding achieves nothing while a broader one + survives. That is the defect both reviews named. + + It cannot be routed through the exact lookup yet, and the reason is structural + rather than effort: the staleness check has no binding id to look up, because + the credential does not carry one. Sealing the binding id is the Work Context + cutover, so this site is **gated on condition 2**, not pending on condition 1. + Until then, `operationScopesSubset` there is bounding-by-search and the + narrowing it misses is real. + + **Deferred work is re-checked when it runs**, not when it is enqueued, because + "at use" has to mean at the moment of use. A producer stamps + `DeferredWorkAuthority`; a worker re-checks it and revoked work fails + **terminally** (`ErrDeferredWorkRevoked`) rather than retrying — the authority + will not come back by waiting. An unreadable authority stays retryable, because + nothing was decided. + + **Absence is not zero, and this is the trap.** `SealedModuleAuthority`'s fields + are pointers, so a credential carrying no claim about a term is skipped rather + than compared against zero. "Treat missing as zero" is the shorter + implementation, it looks exactly like a check, and it admits every unsealed + credential — which is all of them until the Work Context cutover. Core's own + seal made the same change for the same reason: `build_incarnation` and + `image_digest` became optional so "bears no execution" and "bears execution + zero" stopped being the same bytes. + + `TestEveryModuleCapabilityPathReReadsLiveAuthority` in `module/tools` is what + makes "every path" true rather than aspirational: it walks the AST for any + function calling `moduleGrant` directly and holds the set against a + **shrink-only** baseline, now empty. Its non-vacuity check is anchored on the + enforcement rather than on the violations — zero direct callers is the goal, so + deleting the wrapper must not satisfy it. + + **What is NOT built, and so must not be relied on.** The credential seals + identity and tenant only: the mint does not yet populate the installation id, + revision, epoch or binding fields, so in a running deployment every + `Sealed` is nil and the intersection is the live half alone. That is sound for + narrowing and silent about widening, which is strictly better than before and + strictly weaker than the target. The intersection also does not yet cover + queues, resources, namespaces, external publication and audiences; only the + three authority terms above. Those are gated on the Work Context cutover to + core's `workcontext`, tracked on #952 / PR #953 as Lane 3 condition 1. + +## Establishing what a caller is running + +`Service.BindExecution` answers what a caller IS RUNNING, from sources the +caller does not control. The mint authenticated with a **shared secret**, which +is a bearer token — whoever holds it is the module — so it could not answer this +at all. A secret cannot be execution-bound in principle: it says "I know the +secret", never "I am this pod running this image". + +**Three digest types, and conflating any two defeats the check.** They are +separate Go types rather than three strings for exactly that reason: + +| | what it says | who can write it | +| --- | --- | --- | +| `ApprovedDigest` | what the authority document approves | signed, out of band — the caller has no influence | +| `DeclaredDigest` | what the pod **spec** asks to run | whoever can create the pod | +| `RunningDigest` | what the container **status** reports, as `imageID` | the kubelet | + +The check is **approved == running**. Comparing approved against declared is the +classic defeat: a pod spec can name an approved image and run something else if +the tag moved, and the comparison passes while the workload is unapproved. +Comparing declared against running proves only that the pod got what it asked +for, which says nothing about whether it was allowed to ask. `DeclaredDigest` +exists with nothing compared against it so that a reader reaching for "the pod's +image" has a name for the thing they must not use. + +**Three independent sources, so no one of them can satisfy the check.** Filling +both sides from the same place — approved digest and running digest both out of +the authority document — compares a value to itself and passes for every caller. +Here the approved digest comes from the signed document, the running digest from +the Kubernetes API, keyed by a pod UID that came from a TokenReview of a token +the caller could not forge. + +**The order is load-bearing:** authenticate, then read the pod *the token named*, +then compare. Reading the pod first would mean reading a pod the **caller** +named, which is a caller-supplied input; the token is what makes the pod +reference trustworthy. The audience is `accounts`, so a token minted for the API +server's own audience authenticates the same service account while having been +issued for something else. + +**The UID is what is compared, not the name.** A pod name is reused across +generations of a workload and the UID is not, so a replacement pod at the same +name is refused. + +**Two answers that must not be confused.** `ErrExecutionNotApproved` is a verdict +about the caller. `ErrExecutionUnbound` means the host could not *tell* — an +unreachable API, a container still pulling, no reviewer wired — and **nothing is +issued**, because a mint that proceeded would issue an unbound capability exactly +when binding was impossible. A legacy non-bound token is **refused**, not +skipped: skipping would make the check opt-out by presenting an older token, +which is the easiest possible bypass. + +**A tag is never an approval.** The comparison is on the digest portion and is an +exact match on it — not a suffix test (`HasSuffix` passes when the approved +digest is a suffix of a longer hex string) and not a `sha256:` prefix check +(which would let any repository satisfy an approval granted for one image). A +value with no digest never matches, because an approval is only ever a statement +about immutable content. + +### The monotonicity contract, which is this host's to keep + +Core's `SealSource.ApprovedBuild` states the rule and says plainly that it +compares values for **equality** and cannot detect a source that moves +backwards. `MonotonicApprovedBuilds` enforces both clauses: + +1. for one principal, the incarnation never **decreases**; +2. a change of **digest** comes with an **increase** in the incarnation. + +Clause 2 is the one core's own `MemorySealSource` missed: approve B at +incarnation 5, then approve A again at 5, and every capability sealed to A at 5 — +which the move to B revoked — verifies again. A swap-back at a fixed counter is +the rewind the rule exists to prevent, reached through the field clause 1 does +not cover. A violating write is **refused**, not clamped: clamping would serve a +value the writer did not intend and the writer would never learn. A source that +genuinely must rewind is reconstructing state rather than recording it, and +belongs behind a fresh instance. + +**Three states, and collapsing two of them is the dangerous direction:** a record +→ the approved pair; known but no record → `ErrNoApprovedBuild`, and the caller +may act unbound; unknown → `ErrUnknownExecutionPrincipal`, refused. Collapsing +unknown into "bears none" would mint an unbound capability for an identity the +host has never heard of, so an **empty** authority refuses everyone rather than +minting unbound capabilities for everyone. + +A principal bearing no approved build binds with **neither** field set, because +core's seal makes `build_incarnation` and `image_digest` optional *and paired* — +a digest without an incarnation is refused by its schema. + +**Not wired yet:** no mint calls `BindExecution`, and the approved digests are +not populated from the delivered authority documents, so this is exercised by +tests rather than by a running deployment. `module_operation_context.go`'s +binding search is unblocked by the sealed binding id, which the Work Context +cutover carries and which is still outstanding. - The module presents that token in `x-codefly-work-context` on every capability call; accounts takes the calling principal and its bound tenant **from the verified token, never from request metadata**. Work Contexts cap at 15 minutes, diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index 5d9ec538b..c1842c0b8 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -2,7 +2,7 @@ > Generated from protobuf service descriptors and `saas.policy.v1.method_policy`; only the prose description is joined from `pkg/business/introspection.go`. Do not edit by hand. Run `go generate ./pkg/business` from `module/services/accounts/code`. -Inventory: **250 RPCs** across **35 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. +Inventory: **252 RPCs** across **36 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. The compact tier is retained for compatibility. Guards, resource bindings, audit events, limiter class, and data sensitivity are descriptor-authoritative. Domain handlers may enforce stronger state-dependent rules but may not weaken this floor. @@ -81,6 +81,8 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | `/saas.accounts.v1.IdentityService/ResolveIdentity` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | SECRET → CONFIDENTIAL | Internal: provider id → user/org/roles. | | `/saas.accounts.v1.InstallationService/GetInstallation` | unary | `GET /v1/installations/{installation_id}` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER | — | org_id → ORGANIZATION/DIRECT_ID | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Get one installation and its live health. | | `/saas.accounts.v1.InstallationService/InstallSolution` | unary | `POST /v1/installations` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Install a solution: compose its agent principal, scope node, standing grant, and installation row. | +| `/saas.accounts.v1.InstallationService/ListAvailableSolutions` | unary | `GET /v1/installations:listAvailable` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List the solution targets an administrator may install: accepted applied presence, never a refused desired generation. | +| `/saas.accounts.v1.InstallationService/ListInstallations` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | List one organization's installations with their live health. | | `/saas.accounts.v1.InstallationService/TransferInstallationOwnership` | unary | `POST /v1/installations/{installation_id}:transferOwnership` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.ownership_transferred | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Reassign an installation's accountable owner of record. | | `/saas.accounts.v1.InstallationService/UninstallSolution` | unary | `POST /v1/installations/{installation_id}:uninstall` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.installation.revoked | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Uninstall a solution and reverse its composition. | | `/saas.accounts.v1.IntrospectionService/GetServiceInfo` | unary | `GET /v1/.well-known/service-info` | `public` | exposure=PUBLIC; tenant=NONE | — | — | — | FORBIDDEN / PUBLIC | PUBLIC → PUBLIC | Self-describing service catalog (this RPC). | @@ -209,9 +211,9 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | `/saas.accounts.v1.SSOAdminService/Disable` | unary | `POST /v1/sso/disable` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.sso.disabled | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Pause SSO; preserves WorkOS state for re-enable. | | `/saas.accounts.v1.SSOAdminService/GetSSO` | unary | `GET /v1/sso/{org_id}` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Read org SSO state. | | `/saas.accounts.v1.SSOAdminService/StartSetup` | unary | `POST /v1/sso/setup` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.sso.setup.started | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Mint WorkOS portal link. | -| `/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | SUCCESS: saas.solution.registration_deleted | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Deregister a solution and leave a tombstone that blocks resurrection. | +| `/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | List the installed solutions a subject may use in one organization. | +| `/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Read the declared solution bindings: desired, applied and why they differ. | | `/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Read the solution registry snapshot a replica rebuilds its cache from. | -| `/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | SUCCESS: saas.solution.registration_updated | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Write or renew one half of a solution's durable runtime registration. | | `/saas.accounts.v1.TeamService/AddMember` | unary | `POST /v1/teams/{team_id}/members` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | team_id → TEAM/TEAM_TO_ORGANIZATION | SUCCESS: saas.team.member_added | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Add a user to a team. | | `/saas.accounts.v1.TeamService/CreateTeam` | unary | `POST /v1/organizations/{org_id}/teams` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.team.created | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Create a team within an org. | | `/saas.accounts.v1.TeamService/DeleteTeam` | unary | `DELETE /v1/teams/{team_id}` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; impersonation=FORBIDDEN | — | team_id → TEAM/TEAM_TO_ORGANIZATION | SUCCESS: saas.team.deleted | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Delete a team and its memberships. | @@ -262,9 +264,9 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit ## Tier totals - `auth`: 43 -- `internal`: 53 +- `internal`: 54 - `mfa`: 4 -- `org_admin`: 56 +- `org_admin`: 57 - `org_member`: 49 - `platform_admin`: 27 - `public`: 18 diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 1baf6311e..c147d75c9 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -8,7 +8,7 @@ require ( connectrpc.com/connect v1.21.0 connectrpc.com/otelconnect v0.9.0 github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 + github.com/codefly-dev/core v0.9.0 github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e @@ -22,6 +22,7 @@ require ( github.com/prometheus/client_golang v1.24.1 github.com/redis/go-redis/v9 v9.22.0 github.com/rs/cors v1.11.1 + github.com/sigstore/sigstore-go v1.3.0 github.com/stretchr/testify v1.12.1 github.com/svix/svix-webhooks v1.99.1 go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 @@ -54,14 +55,19 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheggaaa/pb/v3 v3.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/fatih/color v1.19.0 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -70,29 +76,42 @@ require ( github.com/go-openapi/jsonpointer v1.0.1 // indirect github.com/go-openapi/jsonreference v1.0.2 // indirect github.com/go-openapi/loads v0.25.3 // indirect + github.com/go-openapi/runtime v0.33.0 // indirect + github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect github.com/go-openapi/spec v1.0.1 // indirect github.com/go-openapi/strfmt v0.27.2 // indirect + github.com/go-openapi/swag v0.28.0 // indirect + github.com/go-openapi/swag/cmdutils v0.28.0 // indirect github.com/go-openapi/swag/conv v0.29.1 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect github.com/go-openapi/swag/jsonutils v0.29.1 // indirect github.com/go-openapi/swag/loading v0.29.1 // indirect github.com/go-openapi/swag/mangling v0.29.1 // indirect + github.com/go-openapi/swag/netutils v0.28.0 // indirect github.com/go-openapi/swag/pools v0.29.1 // indirect github.com/go-openapi/swag/stringutils v0.29.1 // indirect github.com/go-openapi/swag/typeutils v0.29.1 // indirect github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-openapi/validate v0.26.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.3.0 // indirect github.com/gofrs/flock v0.13.1 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect github.com/google/go-github/v89 v89.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/in-toto/attestation v1.2.0 // indirect + github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/letsencrypt/boulder v0.20260309.0 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect @@ -100,6 +119,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect @@ -109,13 +129,29 @@ require ( github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.2.2 // indirect + github.com/sigstore/protobuf-specs v0.5.1 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect + github.com/sigstore/sigstore v1.10.8 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/tinylib/msgp v1.6.4 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect + github.com/transparency-dev/formats v0.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect @@ -126,6 +162,8 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect + golang.org/x/mod v0.41.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect ) diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 8addb8a9b..8db5b54f2 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -6,12 +6,32 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= +filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= @@ -20,6 +40,10 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDH github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.9.0 h1:MDT4FxAPve5FnYn6vOL1r7RCRDG+l9cI7a5LlCuHsqA= @@ -34,8 +58,42 @@ github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67 github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= +github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= +github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw= +github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= @@ -48,6 +106,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -58,10 +118,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba h1:6nFaDYsLyA5x3WiGAk6pMI3c0naqxiPeaTHMqRPvbVs= @@ -78,12 +140,23 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= @@ -104,6 +177,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -112,6 +187,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -130,6 +207,10 @@ github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259 github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= +github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= +github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= +github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= +github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= @@ -164,12 +245,18 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8 github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA= github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg= github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE= github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -178,11 +265,15 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= @@ -191,15 +282,51 @@ github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= +github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -212,6 +339,12 @@ github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInw github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -226,6 +359,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= +github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -234,6 +369,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -244,6 +381,8 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -280,18 +419,54 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7Y= github.com/shoenig/go-m1cpu v0.2.2/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.3.0 h1:hnIMHREyCNTYFtOE1o7ae3Axa9B5W5EjUSBJICP2NBE= +github.com/sigstore/sigstore-go v1.3.0/go.mod h1:AyRQXfpH89py1twjE3kEZxlRersng90GSYqQV9zGJE8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8/go.mod h1:YiTpAsxoWXhF9KlLOVWCh7BckN5cYO8X01WufDq1ido= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9ax1I498oQBp7oYSMgoMSsOmKI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= +github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= +github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -299,12 +474,30 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/svix/svix-webhooks v1.99.1 h1:Wbi3/kn5Sm2sgGZ7mKDjO/eJD/DQj9YdXhCF4VB13qA= github.com/svix/svix-webhooks v1.99.1/go.mod h1:ngWxEvc1ll097e5kjOQfLz6PqjZTep6ORvGsV4s6mYg= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= +github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0 h1:3s6YMgMOBZRU8qG6ybpKSF2Sau+y3sMvxR911M59SwA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0/go.mod h1:X8UNvbQu2wanAGa8ixRUU/DWt1V2hUBfvPGy6s9nE2s= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= +github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= +github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -313,8 +506,12 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -349,22 +546,33 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= +go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -376,8 +584,14 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= @@ -396,11 +610,15 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go index 1d06a9e05..e9ca6c9e1 100644 --- a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go @@ -37,6 +37,24 @@ var forwardedIdentityHeaders = []string{ const publicOriginHeader = "X-Codefly-Public-Origin" +// errSolutionAttestationNotDelivered refuses a request that asserts a solution +// identity. Nothing on this host can prove such a claim: the gateway stamps +// neither header, and the attestation that would replace the deleted +// registration credential is not delivered yet. Named rather than generic so a +// composed solution's runtime reads why it cannot mint, instead of discovering +// that it silently received a capability for something else. +var errSolutionAttestationNotDelivered = errors.New( + "solution attestation is not delivered on this host: a solution-scoped Work Context requires the delivered workload-certificate attestation at the mint hop, which is blocked on the core workcontext cutover (the Seals/Revisions sources both entrypoints require, and the JSON-versus-deterministic-protobuf token encoding)", +) + +// solutionIdentityAsserted reports whether a request claims a solution identity +// at all. Either header alone is a claim: the publisher without the id still +// asserts something this host cannot prove, and refusing only the pair would +// make the refusal depend on how completely a caller lied. +func solutionIdentityAsserted(headers http.Header) bool { + return headers.Get(solutionIdentityHeader) != "" || headers.Get(solutionPublisherHeader) != "" +} + // restIdentityHeaderMatcher forwards the caller's bearer credential and the // canonical identity headers across the REST transcoding hop into gRPC // metadata, so a request that arrives over REST reaches this interceptor @@ -149,6 +167,11 @@ func (i *connectPolicyInterceptor) WrapUnary(next connect.UnaryFunc) connect.Una if err := enforceCentralPolicy(ctx, req.Spec().Procedure); err != nil { return nil, translateGRPCError(err) } + // The policy log's serving gate. After authorization, so the + // refusal reaches only callers this host would have served. + if err := enforcePolicyLogServing(ctx); err != nil { + return nil, translateGRPCError(err) + } shadowPolicyCoverage(ctx, req.Spec().Procedure) return next(ctx, req) } @@ -173,6 +196,9 @@ func (i *connectPolicyInterceptor) WrapStreamingHandler(next connect.StreamingHa if err := enforceCentralPolicy(ctx, conn.Spec().Procedure); err != nil { return translateGRPCError(err) } + if err := enforcePolicyLogServing(ctx); err != nil { + return translateGRPCError(err) + } shadowPolicyCoverage(ctx, conn.Spec().Procedure) return next(ctx, conn) } @@ -183,6 +209,18 @@ func (i *connectPolicyInterceptor) authorize(ctx context.Context, procedure stri if trustedForwarded && forwardedIdentityAmbiguous(headers.Values) { return ctx, connect.NewError(connect.CodePermissionDenied, errors.New("forwarded identity is ambiguous")) } + // Refused here, and not inside stampForwardedHTTPIdentity, for two reasons: + // that function's caller rewrites every error into "forwarded identity is + // malformed", which would bury the one thing a miswired runtime needs to + // read; and it only runs when X-User-Id is also present, while this claim + // must be refused however little else arrives with it. + // + // Scoped to a trusted forward because that is the only way it can arrive + // and be believed: without a valid gateway token these headers are deleted + // a few lines below, which is the right answer for an anonymous prober. + if trustedForwarded && solutionIdentityAsserted(headers) { + return ctx, connect.NewError(connect.CodePermissionDenied, errSolutionAttestationNotDelivered) + } forwardedPublicOrigin := headers.Get(publicOriginHeader) if !trustedForwarded { for _, header := range forwardedIdentityHeaders { @@ -280,12 +318,6 @@ func stampForwardedHTTPIdentity(ctx context.Context, headers http.Header) (conte if scopedRoles := headers.Get("X-Scoped-Roles"); scopedRoles != "" { ctx = withScopedRoles(ctx, parseScopedRoles(scopedRoles)) } - if solution := headers.Get(solutionIdentityHeader); solution != "" { - ctx, err = auth.WithVerifiedSolution(ctx, solution, headers.Get(solutionPublisherHeader)) - if err != nil { - return ctx, err - } - } return withScopedRolesTruncated(ctx, headers.Get("X-Scoped-Roles-Truncated") == "true"), nil } diff --git a/module/services/accounts/code/pkg/adapters/connect_bindings.yaml b/module/services/accounts/code/pkg/adapters/connect_bindings.yaml index 65247c7cd..cb122c063 100644 --- a/module/services/accounts/code/pkg/adapters/connect_bindings.yaml +++ b/module/services/accounts/code/pkg/adapters/connect_bindings.yaml @@ -126,6 +126,11 @@ services: handler: ssoAdminConnectHandler source: kind: business + SolutionEntitlementService: + handler: solutionEntitlementConnectHandler + source: + kind: singleton + name: SolutionEntitlementSingleton SolutionRegistryService: handler: solutionRegistryConnectHandler source: diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers_installations.go b/module/services/accounts/code/pkg/adapters/connect_handlers_installations.go index 926a3f010..309ea9a59 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers_installations.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers_installations.go @@ -31,3 +31,11 @@ func (h *installationConnectHandler) TransferInstallationOwnership(ctx context.C func (h *installationConnectHandler) GetInstallation(ctx context.Context, req *connect.Request[gen.GetInstallationRequest]) (*connect.Response[gen.GetInstallationResponse], error) { return unary(ctx, req, h.inner.GetInstallation) } + +func (h *installationConnectHandler) ListInstallations(ctx context.Context, req *connect.Request[gen.ListInstallationsRequest]) (*connect.Response[gen.ListInstallationsResponse], error) { + return unary(ctx, req, h.inner.ListInstallations) +} + +func (h *installationConnectHandler) ListAvailableSolutions(ctx context.Context, req *connect.Request[gen.ListAvailableSolutionsRequest]) (*connect.Response[gen.ListAvailableSolutionsResponse], error) { + return unary(ctx, req, h.inner.ListAvailableSolutions) +} diff --git a/module/services/accounts/code/pkg/adapters/connect_registration_catalog_gen.go b/module/services/accounts/code/pkg/adapters/connect_registration_catalog_gen.go index a99768225..c4dcefe86 100644 --- a/module/services/accounts/code/pkg/adapters/connect_registration_catalog_gen.go +++ b/module/services/accounts/code/pkg/adapters/connect_registration_catalog_gen.go @@ -13,41 +13,42 @@ import ( // Compile-time assertions keep every configured adapter aligned with the // descriptor-generated Connect handler interface. var ( - _ genconnect.APIKeyServiceHandler = (*apiKeyConnectHandler)(nil) - _ genconnect.AccessibleScopeServiceHandler = (*accessibleScopeConnectHandler)(nil) - _ genconnect.ApprovalReviewServiceHandler = (*approvalReviewConnectHandler)(nil) - _ genconnect.AuditServiceHandler = (*auditConnectHandler)(nil) - _ genconnect.AuthServiceHandler = (*authConnectHandler)(nil) - _ genconnect.BillingServiceHandler = (*billingConnectHandler)(nil) - _ genconnect.ClientRegistryServiceHandler = (*clientRegistryConnectHandler)(nil) - _ genconnect.ConsentServiceHandler = (*consentConnectHandler)(nil) - _ genconnect.DashboardServiceHandler = (*dashboardConnectHandler)(nil) - _ genconnect.DatasourceServiceHandler = (*datasourceConnectHandler)(nil) - _ genconnect.DelegationServiceHandler = (*delegationConnectHandler)(nil) - _ genconnect.DirectoryServiceHandler = (*directoryConnectHandler)(nil) - _ genconnect.GDPRServiceHandler = (*gdprConnectHandler)(nil) - _ genconnect.IdentityServiceHandler = (*identConnectHandler)(nil) - _ genconnect.InstallationServiceHandler = (*installationConnectHandler)(nil) - _ genconnect.IntrospectionServiceHandler = (*introspectionConnectHandler)(nil) - _ genconnect.InvitationServiceHandler = (*invitationConnectHandler)(nil) - _ genconnect.MFAServiceHandler = (*mfaConnectHandler)(nil) - _ genconnect.ModuleCapabilitiesServiceHandler = (*moduleCapabilitiesConnectHandler)(nil) - _ genconnect.NotificationServiceHandler = (*notificationConnectHandler)(nil) - _ genconnect.OnboardingServiceHandler = (*onboardingConnectHandler)(nil) - _ genconnect.OrganizationServiceHandler = (*orgConnectHandler)(nil) - _ genconnect.PermissionServiceHandler = (*permConnectHandler)(nil) - _ genconnect.PlatformAdminServiceHandler = (*platformAdminConnectHandler)(nil) - _ genconnect.PrincipalServiceHandler = (*principalConnectHandler)(nil) - _ genconnect.ResourceFollowServiceHandler = (*resourceFollowConnectHandler)(nil) - _ genconnect.SSOAdminServiceHandler = (*ssoAdminConnectHandler)(nil) - _ genconnect.SolutionRegistryServiceHandler = (*solutionRegistryConnectHandler)(nil) - _ genconnect.TeamServiceHandler = (*teamConnectHandler)(nil) - _ genconnect.UsageServiceHandler = (*usageConnectHandler)(nil) - _ genconnect.UserServiceHandler = (*userConnectHandler)(nil) - _ genconnect.UserSettingsServiceHandler = (*userSettingsConnectHandler)(nil) - _ genconnect.WaitlistServiceHandler = (*waitlistConnectHandler)(nil) - _ genconnect.WebhookServiceHandler = (*webhookConnectHandler)(nil) - _ genconnect.WorkContextServiceHandler = (*workContextConnectHandler)(nil) + _ genconnect.APIKeyServiceHandler = (*apiKeyConnectHandler)(nil) + _ genconnect.AccessibleScopeServiceHandler = (*accessibleScopeConnectHandler)(nil) + _ genconnect.ApprovalReviewServiceHandler = (*approvalReviewConnectHandler)(nil) + _ genconnect.AuditServiceHandler = (*auditConnectHandler)(nil) + _ genconnect.AuthServiceHandler = (*authConnectHandler)(nil) + _ genconnect.BillingServiceHandler = (*billingConnectHandler)(nil) + _ genconnect.ClientRegistryServiceHandler = (*clientRegistryConnectHandler)(nil) + _ genconnect.ConsentServiceHandler = (*consentConnectHandler)(nil) + _ genconnect.DashboardServiceHandler = (*dashboardConnectHandler)(nil) + _ genconnect.DatasourceServiceHandler = (*datasourceConnectHandler)(nil) + _ genconnect.DelegationServiceHandler = (*delegationConnectHandler)(nil) + _ genconnect.DirectoryServiceHandler = (*directoryConnectHandler)(nil) + _ genconnect.GDPRServiceHandler = (*gdprConnectHandler)(nil) + _ genconnect.IdentityServiceHandler = (*identConnectHandler)(nil) + _ genconnect.InstallationServiceHandler = (*installationConnectHandler)(nil) + _ genconnect.IntrospectionServiceHandler = (*introspectionConnectHandler)(nil) + _ genconnect.InvitationServiceHandler = (*invitationConnectHandler)(nil) + _ genconnect.MFAServiceHandler = (*mfaConnectHandler)(nil) + _ genconnect.ModuleCapabilitiesServiceHandler = (*moduleCapabilitiesConnectHandler)(nil) + _ genconnect.NotificationServiceHandler = (*notificationConnectHandler)(nil) + _ genconnect.OnboardingServiceHandler = (*onboardingConnectHandler)(nil) + _ genconnect.OrganizationServiceHandler = (*orgConnectHandler)(nil) + _ genconnect.PermissionServiceHandler = (*permConnectHandler)(nil) + _ genconnect.PlatformAdminServiceHandler = (*platformAdminConnectHandler)(nil) + _ genconnect.PrincipalServiceHandler = (*principalConnectHandler)(nil) + _ genconnect.ResourceFollowServiceHandler = (*resourceFollowConnectHandler)(nil) + _ genconnect.SSOAdminServiceHandler = (*ssoAdminConnectHandler)(nil) + _ genconnect.SolutionEntitlementServiceHandler = (*solutionEntitlementConnectHandler)(nil) + _ genconnect.SolutionRegistryServiceHandler = (*solutionRegistryConnectHandler)(nil) + _ genconnect.TeamServiceHandler = (*teamConnectHandler)(nil) + _ genconnect.UsageServiceHandler = (*usageConnectHandler)(nil) + _ genconnect.UserServiceHandler = (*userConnectHandler)(nil) + _ genconnect.UserSettingsServiceHandler = (*userSettingsConnectHandler)(nil) + _ genconnect.WaitlistServiceHandler = (*waitlistConnectHandler)(nil) + _ genconnect.WebhookServiceHandler = (*webhookConnectHandler)(nil) + _ genconnect.WorkContextServiceHandler = (*workContextConnectHandler)(nil) ) // registerCatalogConnectServices registers every catalog service on one mux. @@ -80,6 +81,7 @@ func registerCatalogConnectServices(mux *http.ServeMux, server *ConnectServer, o mux.Handle(genconnect.NewPrincipalServiceHandler(&principalConnectHandler{inner: PrincipalSingleton()}, options...)) mux.Handle(genconnect.NewResourceFollowServiceHandler(&resourceFollowConnectHandler{svc: server.service}, options...)) mux.Handle(genconnect.NewSSOAdminServiceHandler(&ssoAdminConnectHandler{svc: server.service}, options...)) + mux.Handle(genconnect.NewSolutionEntitlementServiceHandler(&solutionEntitlementConnectHandler{inner: SolutionEntitlementSingleton()}, options...)) mux.Handle(genconnect.NewSolutionRegistryServiceHandler(&solutionRegistryConnectHandler{inner: SolutionRegistrySingleton()}, options...)) mux.Handle(genconnect.NewTeamServiceHandler(&teamConnectHandler{inner: server.grpc.Team}, options...)) mux.Handle(genconnect.NewUsageServiceHandler(&usageConnectHandler{inner: UsageSingleton()}, options...)) diff --git a/module/services/accounts/code/pkg/adapters/current_installation_test.go b/module/services/accounts/code/pkg/adapters/current_installation_test.go index 0c45016b8..e87508a80 100644 --- a/module/services/accounts/code/pkg/adapters/current_installation_test.go +++ b/module/services/accounts/code/pkg/adapters/current_installation_test.go @@ -17,12 +17,21 @@ import ( ) type currentInstallationStore struct { + target *business.SolutionTarget artifactTransportStore installation *gen.Installation reads int tenant, selector string } +// GetSolutionTarget is the host presence state the read resolves through: the +// installation names a target, the target names the binding. Keyed by target id. +func (s *currentInstallationStore) GetSolutionTarget(_ context.Context, targetID string) (*business.SolutionTarget, error) { + if s.target == nil || s.target.ID != targetID { + return nil, nil + } + return s.target, nil +} func (s *currentInstallationStore) GetInstallation(_ context.Context, tenant, id string) (*gen.Installation, gen.InstallationHealth, error) { s.reads++ s.tenant, s.selector = tenant, id @@ -35,8 +44,13 @@ func (s *currentInstallationStore) GetInstallation(_ context.Context, tenant, id func TestCurrentInstallationServedModuleAndParent(t *testing.T) { _, facts, client, mint := sourceReadFixture(t) id := uuid.NewString() - original := &gen.Installation{Id: id, OrgId: readOrg, SolutionIdentifier: "acme/example", Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE} - store := ¤tInstallationStore{artifactTransportStore: artifactTransportStore{member: true}, installation: proto.Clone(original).(*gen.Installation)} + target := uuid.NewString() + original := &gen.Installation{Id: id, OrgId: readOrg, TargetId: target, Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE} + store := ¤tInstallationStore{ + artifactTransportStore: artifactTransportStore{member: true}, + installation: proto.Clone(original).(*gen.Installation), + target: &business.SolutionTarget{ID: target, BindingID: "binding-acme-example-0001", SolutionID: "acme/example"}, + } var err error service, err = business.NewService(store) require.NoError(t, err) @@ -55,10 +69,19 @@ func TestCurrentInstallationServedModuleAndParent(t *testing.T) { require.NoError(t, err) require.Equal(t, id, out.Msg.InstallationId) require.Equal(t, readOrg, out.Msg.TenantId) - require.Equal(t, "acme/example", out.Msg.SolutionIdentifier) + // The identities, and deliberately NOT the route alias: the response + // carries the consented target and the binding an authority decision keys + // on, so a caller cannot compare one binding's presence to its + // replacement's by comparing a reusable string. + require.Equal(t, target, out.Msg.TargetId) + require.Equal(t, "binding-acme-example-0001", out.Msg.BindingId) require.Equal(t, readOrg, store.tenant) require.Equal(t, id, store.selector) - require.Equal(t, 3, out.Msg.ProtoReflect().Descriptor().Fields().Len(), "identity-only response") + // Four fields, not three: `solution_identifier` is reserved and + // `target_id` + `binding_id` replace it. The tripwire's point is unchanged — + // this response carries IDENTITIES and nothing else, so a field added here + // has to be justified at this line rather than slipped in. + require.Equal(t, 4, out.Msg.ProtoReflect().Descriptor().Fields().Len(), "identity-only response") require.Equal(t, 2, request(parent).Msg.ProtoReflect().Descriptor().Fields().Len(), "selector and proof only; no tenant or source input") for _, tc := range []struct { name string @@ -109,7 +132,7 @@ func TestCurrentInstallationServedModuleAndParent(t *testing.T) { {"wrong installation", func(i *gen.Installation) { i.Id = uuid.NewString() }}, {"revoked", func(i *gen.Installation) { i.RevokedAt = timestamppb.Now() }}, {"inactive", func(i *gen.Installation) { i.Status = gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED }}, - {"empty source", func(i *gen.Installation) { i.SolutionIdentifier = "" }}, + {"no consented target", func(i *gen.Installation) { i.TargetId = "" }}, } { t.Run(tc.name, func(t *testing.T) { store.installation = proto.Clone(original).(*gen.Installation) @@ -130,7 +153,12 @@ func TestCurrentInstallationServedModuleAndParent(t *testing.T) { func TestCurrentInstallationDelegationCurrentChecks(t *testing.T) { _, facts, client, _ := sourceReadFixture(t) id := uuid.NewString() - store := ¤tInstallationStore{artifactTransportStore: artifactTransportStore{member: true}, installation: &gen.Installation{Id: id, OrgId: readOrg, SolutionIdentifier: "acme/example", Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE}} + refusalTarget := uuid.NewString() + store := ¤tInstallationStore{ + artifactTransportStore: artifactTransportStore{member: true}, + installation: &gen.Installation{Id: id, OrgId: readOrg, TargetId: refusalTarget, Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE}, + target: &business.SolutionTarget{ID: refusalTarget, BindingID: "binding-acme-example-0001", SolutionID: "acme/example"}, + } var err error service, err = business.NewService(store) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go index 609e4a634..b8231f71b 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -145,7 +145,7 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err } - issued, err := authority.exchangeVerifiedParent(parentToken, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}) + issued, err := authority.exchangeVerifiedParent(ctx, parentToken, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}) if err != nil { audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err @@ -317,7 +317,7 @@ func (s *ModuleCapabilitiesServer) ExchangeDelegatedReadAudience(ctx context.Con // A source delegation authorizes operation bindings only; a read exchange // keeps the caller's own tenant check whatever the parent carries. return s.exchangeDelegatedAudience(ctx, req.GetParentWorkContextToken(), delegatedAudienceRequest{bindingID: req.GetBindingId(), kind: "read"}, func(caller business.ModuleCaller, tenant, parentAudience string, _ *business.SourceDelegation) (delegatedAudienceBinding, error) { - binding, err := service.ModuleReadAudience(caller, tenant, parentAudience, req.BindingId) + binding, err := service.ModuleReadAudience(ctx, caller, tenant, parentAudience, req.BindingId) return delegatedAudienceBinding{audience: binding.Audience, scopes: binding.WireScopes(), policy: binding}, err }) } @@ -339,9 +339,9 @@ func (s *ModuleCapabilitiesServer) ExchangeDelegatedOperationAudience(ctx contex var binding business.ModuleOperationAudience var err error if delegation != nil { - binding, err = service.ModuleOperationAudienceForDelegation(caller, delegation, parentAudience, req.BindingId) + binding, err = service.ModuleOperationAudienceForDelegation(ctx, caller, delegation, parentAudience, req.BindingId) } else { - binding, err = service.ModuleOperationAudience(caller, tenant, parentAudience, req.BindingId) + binding, err = service.ModuleOperationAudience(ctx, caller, tenant, parentAudience, req.BindingId) } return delegatedAudienceBinding{audience: binding.Audience, scopes: binding.WireScopes(req.Lookup), policy: binding}, err }) diff --git a/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go index fad4d5aa4..94c2d37b8 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go @@ -16,6 +16,7 @@ import ( ) func installReadBinding() { + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ business.ModulePrincipalID("example"): {Prefix: "example", Tenant: readOrg, ReadAudiences: map[string]business.ModuleReadAudience{ "proof": {Audience: "example-producer", Scopes: []business.ModuleReadScope{{ResourceKind: "results"}}}, @@ -110,6 +111,7 @@ func TestDelegatedReadExchangeOwnerAndDenials(t *testing.T) { func TestDelegatedOperationExchangeUsesInstalledScopesAndIsolatesRevocation(t *testing.T) { _, facts, client, _ := sourceReadFixture(t) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, installedOperationRegistry(true)) parentToken, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{ Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, @@ -151,6 +153,7 @@ func TestDelegatedOperationExchangeUsesInstalledScopesAndIsolatesRevocation(t *t require.Error(t, workcontext.RequireWorkContextScope(lookup, workcontext.WorkContextScopeRequirement{ResourceKind: "results", ResourceID: "result-1", Action: "write", RequireExplicitResource: true})) require.NoError(t, workcontext.RequireWorkContextScope(lookup, workcontext.WorkContextScopeRequirement{ResourceKind: "results", ResourceID: "result-1", Action: "read", RequireExplicitResource: true})) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, installedOperationRegistry(false)) _, err = exchange("generate", false) require.Error(t, err) @@ -162,6 +165,7 @@ func TestDelegatedOperationExchangeUsesInstalledScopesAndIsolatesRevocation(t *t func TestDelegatedOperationExchangeAuditsVerifiedAttributionAndOutcome(t *testing.T) { _, facts, client, mint := sourceReadFixture(t) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, installedOperationRegistry(true)) audit := &recordingAuditEmitter{} service.SetAuditEmitter(audit) diff --git a/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go b/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go index 633bed5b4..34ffecfb7 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go @@ -21,6 +21,17 @@ type exactRecordStore struct { after func() } +// The audience vocabulary's one read. It declares NO solution bindings: the +// audiences these tests address are declared MODULE prefixes, installed through +// the principal registry, which is where a deployment declares them too. +func (*exactRecordStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (*exactRecordStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + return nil, nil +} + func (s *exactRecordStore) WithOrgTx(ctx context.Context, org string, run func(context.Context) error) error { if err := auth.RequireVerifiedDatabaseScope(ctx, org, readOwner); err != nil { return err @@ -48,8 +59,10 @@ func TestExactRecordOracleRequiresSignedCurrentAttenuatedAuthority(t *testing.T) store := &exactRecordStore{} svc, err := business.NewService(store) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}}) service = svc + declareTestAudiences(svc) token := mint("rows", "rows", "read") out, err := client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token, "record-a")) require.NoError(t, err) @@ -103,8 +116,10 @@ func TestExactRecordOracleRefusesModuleIdentityCapability(t *testing.T) { store := &exactRecordStore{} svc, err := business.NewService(store) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}}) service = svc + declareTestAudiences(svc) module := business.ModulePrincipalID("rows") identity, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: module, Tenant: readOrg}) require.NoError(t, err) @@ -137,8 +152,10 @@ func TestExactRecordOracleIntersectsInstallationCapability(t *testing.T) { store := &exactRecordStore{} svc, err := business.NewService(store) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}}) service = svc + declareTestAudiences(svc) // The sealed revision must equal what the oracle re-resolves, or the mint would // be refused as stale before the intersection this test is about is reached. facts.installationFcts = &business.InstallationAuthorityFacts{ @@ -186,6 +203,7 @@ func TestExactRecordOracleIntersectsExchangedReadAudienceCapability(t *testing.T store := &exactRecordStore{} svc, err := business.NewService(store) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ business.ModulePrincipalID("example"): {Prefix: "example", Tenant: readOrg, Resources: []string{"rows"}, ReadAudiences: map[string]business.ModuleReadAudience{ @@ -193,6 +211,7 @@ func TestExactRecordOracleIntersectsExchangedReadAudienceCapability(t *testing.T business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}, }) service = svc + declareTestAudiences(svc) journal := &exactChainJournal{} workContextSingleton.authority = exactChainAuthority{facts: facts.facts} workContextSingleton.journal = journal @@ -277,8 +296,10 @@ func TestExactRecordOracleChecksSignedActorChainRevocation(t *testing.T) { store := &exactRecordStore{} svc, err := business.NewService(store) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}}) service = svc + declareTestAudiences(svc) workContextSingleton.authority = exactChainAuthority{facts: facts.facts} journal := &exactChainJournal{} workContextSingleton.journal = journal diff --git a/module/services/accounts/code/pkg/adapters/executable_artifacts_test.go b/module/services/accounts/code/pkg/adapters/executable_artifacts_test.go index 8e2971610..d0fc78f19 100644 --- a/module/services/accounts/code/pkg/adapters/executable_artifacts_test.go +++ b/module/services/accounts/code/pkg/adapters/executable_artifacts_test.go @@ -14,6 +14,14 @@ import ( "google.golang.org/protobuf/proto" ) +// artifactTransportTargetID is the consented target this fake's installation +// names, and artifactTransportBindingID the binding it records. The guard keys +// on the binding, so the fake has to answer both rather than a route alias. +const ( + artifactTransportTargetID = "4f1b4b4c-0000-4000-8000-00000000f00d" + artifactTransportBindingID = "binding-acme-example-0001" +) + type artifactTransportStore struct { business.Store business.InstallationStore @@ -22,11 +30,22 @@ type artifactTransportStore struct { admin bool } +// WithControlPlane runs the body as the control plane. The target read is +// control-plane because solution_targets is global with exact grants. +func (s *artifactTransportStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} +func (s *artifactTransportStore) GetSolutionTarget(_ context.Context, targetID string) (*business.SolutionTarget, error) { + if targetID != artifactTransportTargetID { + return nil, nil + } + return &business.SolutionTarget{ID: artifactTransportTargetID, BindingID: artifactTransportBindingID, SolutionID: "acme/example"}, nil +} func (s *artifactTransportStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { return fn(ctx) } func (s *artifactTransportStore) GetInstallation(context.Context, string, string) (*gen.Installation, gen.InstallationHealth, error) { - return &gen.Installation{SolutionIdentifier: "acme/example", Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE}, gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, nil + return &gen.Installation{TargetId: artifactTransportTargetID, Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE}, gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, nil } func (s *artifactTransportStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { if !s.member { @@ -67,7 +86,7 @@ func TestExecutableArtifactServedTransport(t *testing.T) { service, err = business.NewService(store) require.NoError(t, err) digest := "sha256:" + strings.Repeat("a", 64) - policy := business.ExecutableArtifactPolicy{Schema: "example.artifact/v1", Sources: map[string]string{"acme.example": "acme/example"}, Activate: business.ArtifactPermission{Resource: "definitions", Action: "configure"}, Run: business.ArtifactPermission{Resource: "definitions", Action: "run"}, Contracts: []business.ArtifactContract{{Kind: "model", Name: "example/chat", Digest: digest}}, RequiredKinds: []string{"model"}} + policy := business.ExecutableArtifactPolicy{Schema: "example.artifact/v1", Sources: map[string]string{"acme.example": artifactTransportBindingID}, Activate: business.ArtifactPermission{Resource: "definitions", Action: "configure"}, Run: business.ArtifactPermission{Resource: "definitions", Action: "run"}, Contracts: []business.ArtifactContract{{Kind: "model", Name: "example/chat", Digest: digest}}, RequiredKinds: []string{"model"}} service.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{business.ModulePrincipalID("example"): {Prefix: "example", Tenant: readOrg, ArtifactPolicies: map[string]business.ExecutableArtifactPolicy{"content": policy}}}) parent := mint("example", "definitions", "configure") body := &gen.ModuleExecutableArtifactRequest{ParentWorkContextToken: parent, InstallationId: uuid.NewString(), PolicyId: "content", Identity: &gen.ExecutableArtifactIdentity{Schema: policy.Schema, Source: "acme.example", Subject: []byte(`{"configuration":"pinned","scripts":[]}`), ExpectedRevision: 9007199254740993, Contracts: []*gen.ExecutableArtifactContract{{Kind: "model", Name: "example/chat", Digest: digest}}}} diff --git a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go index 00a97fe7a..2f0b35204 100644 --- a/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/grpc_auth_interceptor.go @@ -80,6 +80,11 @@ func grpcAuthInterceptor(getMinter func() auth.JWTMinter, exposure rpcExposure) if err := enforceCentralPolicy(ctx, info.FullMethod); err != nil { return nil, err } + // The policy log's serving gate. After authorization, so the + // refusal reaches only callers this host would have served. + if err := enforcePolicyLogServing(ctx); err != nil { + return nil, err + } shadowPolicyCoverage(ctx, info.FullMethod) return handler(ctx, req) } @@ -101,6 +106,9 @@ func grpcStreamAuthInterceptor(getMinter func() auth.JWTMinter, exposure rpcExpo if err := enforceCentralPolicy(ctx, info.FullMethod); err != nil { return err } + if err := enforcePolicyLogServing(ctx); err != nil { + return err + } shadowPolicyCoverage(ctx, info.FullMethod) return handler(srv, &contextServerStream{ServerStream: stream, ctx: ctx}) } @@ -291,6 +299,14 @@ func (stream *contextServerStream) Context() context.Context { return stream.ctx func (i *grpcPolicyAuthorizer) authorize(ctx context.Context, fullMethod string) (context.Context, error) { md, _ := metadata.FromIncomingContext(ctx) trustedForwarded := singleValidGatewayToken(md.Get("x-codefly-gateway-token")) + // The same unprovable claim over gRPC metadata, refused in the same place + // and for the same reasons as the Connect half (see + // errSolutionAttestationNotDelivered and the comment beside the Connect + // refusal): not inside the stamp helper, whose caller rewrites every error + // into "forwarded identity is malformed". + if trustedForwarded && solutionIdentityAssertedMD(md) { + return ctx, status.Error(codes.PermissionDenied, errSolutionAttestationNotDelivered.Error()) + } if trustedForwarded && forwardedIdentityAmbiguous(md.Get) { return ctx, status.Error(codes.PermissionDenied, "forwarded identity is ambiguous") } @@ -390,6 +406,13 @@ func firstMetadataValue(md metadata.MD, key string) string { return "" } +// solutionIdentityAssertedMD is the gRPC-metadata half of +// solutionIdentityAsserted. Metadata keys are lowercase on the wire. +func solutionIdentityAssertedMD(md metadata.MD) bool { + return firstMetadataValue(md, strings.ToLower(solutionIdentityHeader)) != "" || + firstMetadataValue(md, strings.ToLower(solutionPublisherHeader)) != "" +} + func stampForwardedGRPCIdentity(ctx context.Context, md metadata.MD) (context.Context, error) { if forwardedCredentialIncomplete(md.Get) { return ctx, errForwardedCredentialIncomplete @@ -421,14 +444,6 @@ func stampForwardedGRPCIdentity(ctx context.Context, md metadata.MD) (context.Co if scopedRoles := firstMetadataValue(md, "x-scoped-roles"); scopedRoles != "" { ctx = withScopedRoles(ctx, parseScopedRoles(scopedRoles)) } - if solution := firstMetadataValue(md, strings.ToLower(solutionIdentityHeader)); solution != "" { - ctx, err = auth.WithVerifiedSolution( - ctx, solution, firstMetadataValue(md, strings.ToLower(solutionPublisherHeader)), - ) - if err != nil { - return ctx, err - } - } return withScopedRolesTruncated(ctx, firstMetadataValue(md, "x-scoped-roles-truncated") == "true"), nil } diff --git a/module/services/accounts/code/pkg/adapters/grpc_registration_catalog_gen.go b/module/services/accounts/code/pkg/adapters/grpc_registration_catalog_gen.go index bbca58174..b72608554 100644 --- a/module/services/accounts/code/pkg/adapters/grpc_registration_catalog_gen.go +++ b/module/services/accounts/code/pkg/adapters/grpc_registration_catalog_gen.go @@ -30,6 +30,7 @@ func registerCatalogGRPCServices(registrar grpc.ServiceRegistrar, server *GrpcSe gen.RegisterPermissionServiceServer(registrar, server.Perm) gen.RegisterPlatformAdminServiceServer(registrar, server.PlatformAdmin) gen.RegisterPrincipalServiceServer(registrar, PrincipalSingleton()) + gen.RegisterSolutionEntitlementServiceServer(registrar, SolutionEntitlementSingleton()) gen.RegisterSolutionRegistryServiceServer(registrar, SolutionRegistrySingleton()) gen.RegisterTeamServiceServer(registrar, server.Team) gen.RegisterUsageServiceServer(registrar, UsageSingleton()) @@ -56,6 +57,7 @@ var catalogGRPCServiceNames = []string{ "saas.accounts.v1.PermissionService", "saas.accounts.v1.PlatformAdminService", "saas.accounts.v1.PrincipalService", + "saas.accounts.v1.SolutionEntitlementService", "saas.accounts.v1.SolutionRegistryService", "saas.accounts.v1.TeamService", "saas.accounts.v1.UsageService", diff --git a/module/services/accounts/code/pkg/adapters/installation_rpcs.go b/module/services/accounts/code/pkg/adapters/installation_rpcs.go index cab72fe48..e128afa76 100644 --- a/module/services/accounts/code/pkg/adapters/installation_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/installation_rpcs.go @@ -34,7 +34,7 @@ func (s *InstallationServer) InstallSolution(ctx context.Context, req *gen.Insta installation, err := service.InstallSolution(ctx, actorID, &business.InstallSolutionParams{ OrgID: req.GetOrgId(), AgentIdentifier: req.GetAgentIdentifier(), - SolutionIdentifier: req.GetSolutionIdentifier(), + TargetID: req.GetTargetId(), DisplayName: req.GetDisplayName(), RootScopeLabel: req.GetRootScopeLabel(), RoleID: req.GetRoleId(), @@ -105,6 +105,64 @@ func (s *InstallationServer) GetInstallation(ctx context.Context, req *gen.GetIn return &gen.GetInstallationResponse{Installation: installation, Health: health}, nil } +// ListInstallations enumerates one organization's installations. Internal-tier, +// like the scope listing it is read beside: the organization is a request field, +// so a bare tenant JWT must never be able to ask this — the caller that may is +// the auth-gateway, naming the tenant it projected from a verified identity. +func (s *InstallationServer) ListInstallations(ctx context.Context, req *gen.ListInstallationsRequest) (*gen.ListInstallationsResponse, error) { + if err := Validate(req); err != nil { + return nil, err + } + if err := requireInternalCredential(ctx); err != nil { + return nil, err + } + resp, err := service.ListInstallations(ctx, req) + if err != nil { + return nil, mapInstallationError(err) + } + return resp, nil +} + +// ListAvailableSolutions serves the catalogue. Authenticated and org-admin: it +// reports which presences this host has applied, which is operational state an +// organisation's administrator may see and a member may not — and it reports +// whether THIS organisation already installed each one, which is that +// organisation's own fact. +func (s *InstallationServer) ListAvailableSolutions( + ctx context.Context, req *gen.ListAvailableSolutionsRequest, +) (*gen.ListAvailableSolutionsResponse, error) { + if err := Validate(req); err != nil { + return nil, err + } + actorID, err := requireAuth(ctx) + if err != nil { + return nil, err + } + if err := requireOrgAdmin(ctx, actorID, req.GetOrgId()); err != nil { + return nil, err + } + available, next, err := service.ListAvailableSolutions( + ctx, req.GetOrgId(), req.GetPageToken(), int(req.GetPageSize())) + if err != nil { + return nil, mapInstallationError(err) + } + out := make([]*gen.AvailableSolution, 0, len(available)) + for _, entry := range available { + out = append(out, &gen.AvailableSolution{ + TargetId: entry.TargetID, + BindingId: entry.BindingID, + RouteAlias: entry.RouteAlias, + ReleasePublisher: entry.ReleasePublisher, + ReleaseName: entry.ReleaseName, + ReleaseVersion: entry.ReleaseVersion, + OpenedGeneration: entry.OpenedGeneration, + AppliedGeneration: entry.AppliedGeneration, + Installed: entry.Installed, + }) + } + return &gen.ListAvailableSolutionsResponse{Solutions: out, NextPageToken: next}, nil +} + func mapInstallationError(err error) error { if err == nil { return nil diff --git a/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go b/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go index d08d9446c..28b02f9d6 100644 --- a/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go @@ -37,6 +37,16 @@ func newInstallationMintServer(t *testing.T, facts *business.InstallationAuthori Authority: &workContextAuthorityFake{installationFcts: facts, installationErr: err}, }) require.NoError(t, server.configureErr) + // Every mint resolves the host's audience vocabulary, so a server with no + // service wired refuses before it reaches the mint these tests are about. The + // audiences they address are declared module prefixes, which is the set's third + // member and what they are in a deployment too. + previous := service + svc, svcErr := business.NewService(renewMembershipStore{}) + require.NoError(t, svcErr) + service = svc + declareTestAudiences(svc) + t.Cleanup(func() { service = previous }) return server } diff --git a/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go b/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go index 4466b52c4..20cb26e5c 100644 --- a/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go +++ b/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go @@ -63,7 +63,7 @@ const ( // the runtime module with one operation binding the parent is exchanged // through. const contractPrincipals = `{` + - `"` + contractSourceModule + `":{"tenant":"` + contractModuleOrg + `","operation_audiences":{` + + `"` + contractSourceModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + contractModuleOrg + `","operation_audiences":{` + `"source-sync":{"audience":"` + contractRuntimeModule + `",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + @@ -72,7 +72,7 @@ const contractPrincipals = `{` + `"invoke_scopes":[{"resource_kind":"indexes","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"indexes","actions":["read"]}],` + `"headless_scopes":[{"resource_kind":"indexes","actions":["write"]}]}}},` + - `"` + contractRuntimeModule + `":{"tenant":"` + contractModuleOrg + `","operation_audiences":{` + + `"` + contractRuntimeModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + contractModuleOrg + `","operation_audiences":{` + `"ingest":{"audience":"docstore-ingest",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]}}}}` @@ -220,6 +220,7 @@ func installModuleAuthorityContract(t *testing.T, input moduleAuthorityContractI contractRuntimeModule + ":" + registrationDigest(input.Secrets[contractRuntimeModule])) require.NoError(t, err) svc.SetModuleIdentitySecrets(secrets) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, registry) svc.SetAuditEmitter(&validatingAudit{}) WithService(svc) diff --git a/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go b/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go new file mode 100644 index 000000000..199d43d52 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go @@ -0,0 +1,29 @@ +package adapters + +import ( + "context" + + "accounts/pkg/business" +) + +// currentModuleAuthority is a live-authority read reporting a CURRENT +// installation, wired through the real seam like this package's other fakes. +// +// Enforcement at use made every capability test in this package fail closed: +// a Service that declares module principals but cannot re-read live authority +// now refuses every capability rather than deciding on the declared ceiling +// alone. That is the enforcement working, so the tests wire the read rather +// than the Service growing a permissive default — a default would make the +// check optional in exactly the configuration tests run in, which is the one +// configuration where it must not be. +// +// Revision and epoch are 0 on purpose: a credential sealed at any non-zero +// value is still refused through this, so a test proving a stale seal is +// rejected needs no opt-out. +type currentModuleAuthority struct{} + +func (currentModuleAuthority) LiveModuleAuthority( + _ context.Context, _, _, _ string, +) (*business.LiveModuleAuthority, error) { + return &business.LiveModuleAuthority{InstallationID: "installation-current"}, nil +} diff --git a/module/services/accounts/code/pkg/adapters/module_capabilities_server_test.go b/module/services/accounts/code/pkg/adapters/module_capabilities_server_test.go index da9d58d49..d261ac229 100644 --- a/module/services/accounts/code/pkg/adapters/module_capabilities_server_test.go +++ b/module/services/accounts/code/pkg/adapters/module_capabilities_server_test.go @@ -224,6 +224,7 @@ func installBlobStreamService(t *testing.T, store business.Store, cipher busines // fetch never leaves the process. Must come after the connector, which only // seeds the factory when it is still nil. svc.SetDatasourceGitHubClientFactory(func(string) business.GitHubContentClient { return gh }) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, registry) service = svc t.Cleanup(func() { service = previous }) diff --git a/module/services/accounts/code/pkg/adapters/module_installation_policy_test.go b/module/services/accounts/code/pkg/adapters/module_installation_policy_test.go index 1a999ce00..55ca7c40c 100644 --- a/module/services/accounts/code/pkg/adapters/module_installation_policy_test.go +++ b/module/services/accounts/code/pkg/adapters/module_installation_policy_test.go @@ -11,8 +11,8 @@ import ( ) func installerPolicyFixture() (*business.InstallerPolicy, business.ModuleCaller, business.ModuleInstallationRequest) { - d := business.InstallerDelegation{Prefix: "example-installer", OrganizationID: "11111111-1111-4111-8111-111111111111", ModuleID: "acme.example/solution", AgentIdentifiers: []string{"acme.example/solution:1.0.0"}, SolutionIdentifier: "example-solution", RoleID: "22222222-2222-4222-8222-222222222222", RolePermissions: []string{"documents:read"}, AllowedAudiences: []string{"example.api"}, AllowedScopes: []string{"documents"}, OwnerPrincipalID: "33333333-3333-4333-8333-333333333333", ExpiresAt: time.Now().Add(time.Hour)} - return &business.InstallerPolicy{Version: "accounts.module-installation-policy/v1", Delegations: []business.InstallerDelegation{d}}, business.ModuleCaller{PrincipalID: business.ModulePrincipalID(d.Prefix), BoundOrg: d.OrganizationID}, business.ModuleInstallationRequest{ModuleID: d.ModuleID, OrganizationSlug: "example-org", AgentIdentifier: d.AgentIdentifiers[0], SolutionIdentifier: d.SolutionIdentifier, RoleID: d.RoleID, ExpectedRolePermissions: d.RolePermissions, AllowedAudiences: d.AllowedAudiences, AllowedScopes: d.AllowedScopes} + d := business.InstallerDelegation{Prefix: "example-installer", OrganizationID: "11111111-1111-4111-8111-111111111111", ModuleID: "acme.example/solution", AgentIdentifiers: []string{"acme.example/solution:1.0.0"}, TargetID: "44444444-4444-4444-8444-444444444444", RoleID: "22222222-2222-4222-8222-222222222222", RolePermissions: []string{"documents:read"}, AllowedAudiences: []string{"example.api"}, AllowedScopes: []string{"documents"}, OwnerPrincipalID: "33333333-3333-4333-8333-333333333333", ExpiresAt: time.Now().Add(time.Hour)} + return &business.InstallerPolicy{Version: "accounts.module-installation-policy/v1", Delegations: []business.InstallerDelegation{d}}, business.ModuleCaller{PrincipalID: business.ModulePrincipalID(d.Prefix), BoundOrg: d.OrganizationID}, business.ModuleInstallationRequest{ModuleID: d.ModuleID, OrganizationSlug: "example-org", AgentIdentifier: d.AgentIdentifiers[0], TargetID: d.TargetID, RoleID: d.RoleID, ExpectedRolePermissions: d.RolePermissions, AllowedAudiences: d.AllowedAudiences, AllowedScopes: d.AllowedScopes} } func TestInstallerDelegationScopes(t *testing.T) { for _, tc := range []struct { @@ -32,7 +32,7 @@ func TestInstallerDelegationScopes(t *testing.T) { r.AgentIdentifier = "acme.example/solution:2.0.0" }}, {"solution expansion", func(_ *business.InstallerPolicy, _ *business.ModuleCaller, r *business.ModuleInstallationRequest) { - r.SolutionIdentifier = "another" + r.TargetID = "55555555-5555-4555-8555-555555555555" }}, {"role expansion", func(_ *business.InstallerPolicy, _ *business.ModuleCaller, r *business.ModuleInstallationRequest) { r.RoleID = "another" diff --git a/module/services/accounts/code/pkg/adapters/module_installations_http.go b/module/services/accounts/code/pkg/adapters/module_installations_http.go index 50dddb0be..593b5ef09 100644 --- a/module/services/accounts/code/pkg/adapters/module_installations_http.go +++ b/module/services/accounts/code/pkg/adapters/module_installations_http.go @@ -136,6 +136,22 @@ func (h *ModuleInstallationHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http installerError(w, http.StatusConflict, se.Error()) return } + // A ceiling naming an audience this host does not serve is the CALLER'S + // request being wrong, not this host being unavailable, and the two send an + // installer to opposite places: a 503 says "the host is broken, retry" and + // invites exactly the retry loop that will never succeed, while this says + // "the audience you asked for is not one I serve" and names the entries. + // + // It is mapped explicitly because the fall-through below is 503, so the + // refusal RequireHostAudiences added would otherwise arrive as "persistence + // unavailable" — the same misleading-cause failure the write-time check was + // added to end, moved one layer out. The message is the error's own: it + // echoes back the entries the caller supplied plus a constant shape, so it + // reveals no part of the host's vocabulary. + if errors.Is(err, business.ErrAudienceNotInHostVocabulary) { + installerError(w, http.StatusForbidden, err.Error()) + return + } installerError(w, http.StatusServiceUnavailable, "installation prerequisite or persistence unavailable") return } diff --git a/module/services/accounts/code/pkg/adapters/module_operation_context_test.go b/module/services/accounts/code/pkg/adapters/module_operation_context_test.go index 3732b91aa..fc71c807b 100644 --- a/module/services/accounts/code/pkg/adapters/module_operation_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_operation_context_test.go @@ -20,7 +20,7 @@ import ( // headlessPrincipals declares one module with two operation bindings: one whose // headless grant pins a single resource its invoke grant leaves kind-wide, and // one declared for use on a person's behalf only. -const headlessPrincipals = `{"documents":{"tenant":"` + moduleWorkContextTenant + `","operation_audiences":{` + +const headlessPrincipals = `{"documents":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `","operation_audiences":{` + `"model":{"audience":"modelservice",` + `"invoke_scopes":[{"resource_kind":"modelservice.profiles","actions":["invoke","read"]}],` + `"lookup_scopes":[{"resource_kind":"modelservice.profiles","actions":["read"]}],` + diff --git a/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go b/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go index 6e508327b..0f3ccd4cc 100644 --- a/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go +++ b/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go @@ -164,6 +164,7 @@ func TestCheckAuthorizationRevisionRevokesModuleOperationContextsOnConfigChange( // is unchanged, so only the declaration can revoke the context. registry, err := business.ParseModulePrincipalRegistry(changed) require.NoError(t, err) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, registry) _, err = WorkContextSingleton().CheckAuthorizationRevision(ctx, request) diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_test.go b/module/services/accounts/code/pkg/adapters/module_work_context_test.go index 5aa3e3849..62b109893 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_test.go @@ -19,7 +19,7 @@ import ( const ( moduleWorkContextTenant = "55555555-5555-4555-8555-555555555555" - documentsPrincipals = `{"documents":{"queues":["datasource"],"namespaces":["document"],"tenant":"` + + documentsPrincipals = `{"documents":{"queues":["datasource"],"namespaces":["document"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `"}}` ) @@ -103,6 +103,7 @@ func installModuleWorkContextService(t *testing.T, declaredSecrets, declaredPrin svc.SetModuleIdentitySecrets(secrets) registry, err := business.ParseModulePrincipalRegistry(declaredPrincipals) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, registry) WithService(svc) return store @@ -149,6 +150,7 @@ func TestMintModuleWorkContextDeniesUnconfiguredIdentity(t *testing.T) { installModuleRegistrar(t, "documents:"+registrationDigest("registration-secret")) registry, err := business.ParseModulePrincipalRegistry(documentsPrincipals) require.NoError(t, err) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, registry) installModuleWorkContextAuthority(t) @@ -309,8 +311,8 @@ func TestMintModuleWorkContextReportsAnUnconfiguredAuthority(t *testing.T) { func TestMintModuleWorkContextBindsSecretToPrefix(t *testing.T) { installModuleWorkContextService(t, "documents:"+registrationDigest("documents-secret")+",billing:"+registrationDigest("billing-secret"), - `{"documents":{"queues":["datasource"],"tenant":"`+moduleWorkContextTenant+`"},`+ - `"billing":{"queues":["billing"],"tenant":"`+moduleWorkContextTenant+`"}}`) + `{"documents":{"queues":["datasource"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"`+moduleWorkContextTenant+`"},`+ + `"billing":{"queues":["billing"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"`+moduleWorkContextTenant+`"}}`) installModuleWorkContextAuthority(t) _, err := mintModuleWorkContext(t, "billing", "documents-secret") diff --git a/module/services/accounts/code/pkg/adapters/permission_servers.go b/module/services/accounts/code/pkg/adapters/permission_servers.go index 37f673237..e83d235d6 100644 --- a/module/services/accounts/code/pkg/adapters/permission_servers.go +++ b/module/services/accounts/code/pkg/adapters/permission_servers.go @@ -16,6 +16,8 @@ var ( installationSingleton = &InstallationServer{} accessibleScopeSingleton = &AccessibleScopeServer{} directorySingleton = &DirectoryServer{} + // The per-viewer solution projection's authority read (issue #949). + solutionEntitlementSingleton = &SolutionEntitlementServer{} ) func PrincipalSingleton() *PrincipalServer { return principalSingleton } @@ -32,6 +34,8 @@ func AccessibleScopeSingleton() *AccessibleScopeServer { return accessibleScopeS func DirectorySingleton() *DirectoryServer { return directorySingleton } +func SolutionEntitlementSingleton() *SolutionEntitlementServer { return solutionEntitlementSingleton } + func configurePermissionServerKeys() { plugin := permissionsplugin.Default() delegationSingleton.SigningSecret = plugin.HMACSecret() // gitleaks:allow -- runtime key assignment diff --git a/module/services/accounts/code/pkg/adapters/platform_catalogue_rpcs_test.go b/module/services/accounts/code/pkg/adapters/platform_catalogue_rpcs_test.go index cba1d8a5f..ea44fa84c 100644 --- a/module/services/accounts/code/pkg/adapters/platform_catalogue_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/platform_catalogue_rpcs_test.go @@ -41,29 +41,35 @@ func (s *catalogueStore) ListCatalogueInstallations(context.Context) ([]*busines } // The Catalogue is a browser-facing projection of the registry, so it withholds -// the topology every other browser projection withholds — and the runtime -// boundary every response withholds — while keeping what the derived status is -// read from. +// the topology every other browser projection withholds — and carries no runtime +// boundary field at all — while keeping what the derived status is read from. func TestListPlatformCatalogueWithholdsRegistryTopology(t *testing.T) { - lease := time.Now().UTC().Add(time.Minute) store := &catalogueStore{ role: "super_admin", registrations: []*business.SolutionRegistration{{ - SolutionID: "example-solution", - Publisher: "solution:example-solution", - Revision: 12, - RuntimeBoundary: boundaryStoredSeed, + SolutionID: "example-solution", + Publisher: "solution:example-solution", + Revision: 12, Frontend: &business.SolutionFrontendHalf{ - Revision: 11, Manifest: `{"id":"example-solution"}`, ContractVersion: "v1", LeaseExpiresAt: lease, + Revision: 11, Manifest: `{"id":"example-solution"}`, ContractVersion: "v1", }, Backend: &business.SolutionBackendHalf{ Revision: 12, Upstream: "http://upstream.example:8080", ServiceAlias: "example-solution", - ContractVersion: "v1", LeaseExpiresAt: lease, + ContractVersion: "v1", + }, + // An installation names the immutable target, so the declared + // record is what maps it back to this solution. Without it the + // installation resolves to no solution and is keyed by its target + // id instead — which is correct behaviour, and not what this test + // is about. + Declared: &business.SolutionDeclaredBinding{ + BindingID: "acme.test.example-solution", Generation: 1, + Release: "acme/example-solution@1.0.0", TargetID: platformTargetD, }, UpdatedAt: time.Now().UTC(), }}, installations: []*business.CatalogueInstallationRecord{{ - Installation: &gen.Installation{Id: platformTargetD, SolutionIdentifier: "example-solution"}, + Installation: &gen.Installation{Id: platformTargetD, TargetId: platformTargetD}, OrgName: "Acme", }}, } @@ -83,11 +89,19 @@ func TestListPlatformCatalogueWithholdsRegistryTopology(t *testing.T) { registration := entry.GetRegistration() require.Equal(t, gen.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE, registration.GetStatus()) - require.Empty(t, registration.GetRuntimeBoundary()) + // THE BOUNDARY IS WITHHELD STRUCTURALLY, not emptied. This asserted + // `GetRuntimeBoundary()` was empty; the wire message has no such field at + // all, so there is no getter to call and nothing a future change could + // accidentally populate. Asserted over the DESCRIPTOR rather than a value, + // because "the field does not exist" is the guarantee — a seed is the one + // thing that must never leave this host, and an absent field cannot leak. + require.Nil(t, registration.ProtoReflect().Descriptor().Fields().ByName("runtime_boundary"), + "saas.accounts.v1.SolutionRegistration must carry no runtime_boundary field: "+ + "the seed selects the boundary a Work Context is sealed under, so a response that could "+ + "carry it lets one solution learn another's") require.Empty(t, registration.GetFrontend().GetManifest()) require.Empty(t, registration.GetBackend().GetUpstream()) require.Empty(t, registration.GetBackend().GetServiceAlias()) require.Equal(t, int64(11), registration.GetFrontend().GetRevision()) require.Equal(t, "v1", registration.GetBackend().GetContractVersion()) - require.NotNil(t, registration.GetBackend().GetLeaseExpiresAt()) } diff --git a/module/services/accounts/code/pkg/adapters/policy_log_serving_gate.go b/module/services/accounts/code/pkg/adapters/policy_log_serving_gate.go new file mode 100644 index 000000000..22086a1ee --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/policy_log_serving_gate.go @@ -0,0 +1,51 @@ +package adapters + +import ( + "context" + "sync/atomic" +) + +// The policy log's serving gate, as the transports reach it. +// +// WHY A REGISTERED GATE RATHER THAN A SERVICE REFERENCE. The authorization +// interceptors are built by generated code (grpc_gen.go, connect_gen.go) which +// hands them a minter accessor and nothing else, and generated code is not +// hand-editable. The host registers the gate at boot the same way it registers +// its non-proto HTTP routes, and the interceptors call it through this file. +// +// WHY AN UNSET GATE ADMITS. Registration is boot wiring: a deployment with no +// policy log has nothing unreconciled to honour, which is exactly what +// Service.MayServe answers for a host with no log. The refusal that a host +// without a log owes is on the NARROWING path — it cannot reduce authority — +// and WithPolicyLoggedNarrowing makes it there, not here. Refusing every +// request instead would take a host out of service for a protocol it is not +// participating in. +var policyLogServingGate atomic.Pointer[func(context.Context) error] + +// RegisterPolicyLogServingGate installs the host's serving gate. +// +// Boot wiring, called once by the host before it listens. It takes the function +// rather than the service so this package keeps no handle on the service beyond +// the one question it asks. +func RegisterPolicyLogServingGate(gate func(ctx context.Context) error) { + if gate == nil { + policyLogServingGate.Store(nil) + return + } + policyLogServingGate.Store(&gate) +} + +// enforcePolicyLogServing refuses a call when the policy log says this host must +// not serve. +// +// It runs on every unary and streaming call of both transports, AFTER +// authorization rather than before it: an unauthenticated caller learns nothing +// about this host's reconciliation state, and the refusal reaches only callers +// that would otherwise have been served. +func enforcePolicyLogServing(ctx context.Context) error { + gate := policyLogServingGate.Load() + if gate == nil { + return nil + } + return (*gate)(ctx) +} diff --git a/module/services/accounts/code/pkg/adapters/policy_log_serving_gate_test.go b/module/services/accounts/code/pkg/adapters/policy_log_serving_gate_test.go new file mode 100644 index 000000000..b89be1ac5 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/policy_log_serving_gate_test.go @@ -0,0 +1,91 @@ +package adapters + +import ( + "context" + "errors" + "go/ast" + "go/parser" + "go/token" + "testing" + + "github.com/stretchr/testify/require" +) + +// The gate is consulted, and it is consulted on every call both transports +// serve. +// +// Two halves, because the regression this file exists to catch has two shapes. +// One is a gate that is registered and then never asked — which is the state +// this whole protocol was in before: built, tested, and on no code path. The +// other is a gate asked on the unary path only, so a streaming call serves +// straight through a host that must not be serving. + +func TestARegisteredGateRefusesTheCall(t *testing.T) { + t.Cleanup(func() { RegisterPolicyLogServingGate(nil) }) + + refusal := errors.New("not serving") + var asked int + RegisterPolicyLogServingGate(func(context.Context) error { + asked++ + return refusal + }) + + require.ErrorIs(t, enforcePolicyLogServing(context.Background()), refusal) + require.Equal(t, 1, asked, "the gate must be asked, not assumed") +} + +func TestNoRegisteredGateAdmits(t *testing.T) { + t.Cleanup(func() { RegisterPolicyLogServingGate(nil) }) + RegisterPolicyLogServingGate(nil) + + // A deployment with no policy log has narrowed nothing through the + // protocol, so it has nothing unreconciled to honour. The refusal such a + // host owes is on the narrowing path, not here. + require.NoError(t, enforcePolicyLogServing(context.Background())) +} + +// Every authorization path of both transports asks the gate. +// +// A source check rather than four transport round trips, because what can +// regress is a missing CALL: the interceptors are four nearly identical +// sequences of enforce* steps, and a step dropped from one of them is invisible +// to any test that drives the other three. The four functions are named +// explicitly so that a NEW interceptor path does not silently inherit a pass. +func TestBothTransportsAskTheGateOnEveryPath(t *testing.T) { + want := map[string]string{ + "grpc_auth_interceptor.go:grpcAuthInterceptor": "unary gRPC", + "grpc_auth_interceptor.go:grpcStreamAuthInterceptor": "streaming gRPC", + "connect_auth_interceptor.go:WrapUnary": "unary Connect", + "connect_auth_interceptor.go:WrapStreamingHandler": "streaming Connect", + } + found := map[string]bool{} + + for _, file := range []string{"grpc_auth_interceptor.go", "connect_auth_interceptor.go"} { + fileSet := token.NewFileSet() + parsed, err := parser.ParseFile(fileSet, file, nil, 0) + require.NoError(t, err) + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + key := file + ":" + fn.Name.Name + ast.Inspect(fn, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "enforcePolicyLogServing" { + found[key] = true + } + return true + }) + } + } + + for key, what := range want { + require.True(t, found[key], + "%s (%s) does not consult the policy log serving gate, so that path serves a host that must not be serving", + key, what) + } +} diff --git a/module/services/accounts/code/pkg/adapters/principal_rpcs.go b/module/services/accounts/code/pkg/adapters/principal_rpcs.go index 9cf1e8518..81511f11c 100644 --- a/module/services/accounts/code/pkg/adapters/principal_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/principal_rpcs.go @@ -297,19 +297,6 @@ func (s *PermServer) Decide(ctx context.Context, req *gen.DecideRequest) (*gen.D // principalToProto converts the business Principal into the wire form. // Centralized so display-name / org_id encoding stays consistent. -// -// Not every principal this projects is a `principals` row, which the proto's own -// comment on the message still describes as a 1:1 mirror. ListPrincipals also -// names the composed modules declared in MODULE_PRINCIPALS: derived service -// principals that act in the organization and are the actor of the audit rows -// they record, with no row behind them. Such an entry has no creation time — -// left unset below rather than reported as the year 1 — and GetPrincipal, -// RevokePrincipal, DisableAgentPrincipal and EnableAgentPrincipal all resolve -// through the store, so they answer NotFound for it. That is the fail-closed -// answer and must stay that way: its authority comes from the deployment -// declaration, not from a row, so a revoke that appeared to succeed would leave -// the module holding every capability it had. Removing its authority means -// changing MODULE_PRINCIPALS. func principalToProto(p *business.Principal) *gen.Principal { if p == nil { return nil @@ -326,8 +313,7 @@ func principalToProto(p *business.Principal) *gen.Principal { AllowedScopes: p.AllowedScopes, DisabledReason: p.DisabledReason, } - // A declared module principal has no row and so no creation time; leave the - // field unset rather than report the year 1. + // Do not serialize an absent timestamp as the year 1. if !p.CreatedAt.IsZero() { out.CreatedAt = timestamppb.New(p.CreatedAt) } diff --git a/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go b/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go index 6837314f6..5d7fca154 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go @@ -53,6 +53,7 @@ func TestSourceReadPostgresSignedRPC(t *testing.T) { defer store.Close() service, err = business.NewService(store) require.NoError(t, err) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ business.ModulePrincipalID("documents"): {Prefix: "documents", Resources: []string{"documents"}}, business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}, diff --git a/module/services/accounts/code/pkg/adapters/readable_sources_test.go b/module/services/accounts/code/pkg/adapters/readable_sources_test.go index 13116d17b..bf726d559 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources_test.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources_test.go @@ -190,6 +190,7 @@ func sourceReadFixture(t *testing.T) (*readProjectionStore, *workContextAuthorit require.NoError(t, err) // Two composed modules whose content is governed by unrelated resource types: // nothing this host authorizes may depend on which of them is calling. + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ business.ModulePrincipalID("documents"): {Prefix: "documents", Resources: []string{"documents"}}, business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{"rows"}}, @@ -388,6 +389,7 @@ func TestSourceReadRefusesAnEmptyDeclarationBeforeStorage(t *testing.T) { // rather than falling back to a resource type this host invented. func TestSourceReadDeniesModulesWithNoDeclaredResources(t *testing.T) { store, _, client, mint := sourceReadFixture(t) + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ business.ModulePrincipalID("documents"): {Prefix: "documents"}, }) diff --git a/module/services/accounts/code/pkg/adapters/registration_cutover_test.go b/module/services/accounts/code/pkg/adapters/registration_cutover_test.go new file mode 100644 index 000000000..6db931d3d --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/registration_cutover_test.go @@ -0,0 +1,56 @@ +package adapters + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// Exercise the actual server dispatch without opening a network listener. +func TestDeletedRegistrationRPCsAreUnimplemented(t *testing.T) { + server, err := NewGrpServer(&Configuration{}) + require.NoError(t, err) + for name, transport := range map[string]*grpc.Server{ + "tenant": server.gRPC, "internal": server.internalGRPC, "authority": server.authorityGRPC, + } { + t.Cleanup(transport.Stop) + for _, method := range []string{"PutSolutionRegistration", "DeleteSolutionRegistration"} { + t.Run(name+"/"+method, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/saas.accounts.v1.SolutionRegistryService/"+method, bytes.NewReader([]byte{0, 0, 0, 0, 0})) + req.Proto, req.ProtoMajor, req.ProtoMinor = "HTTP/2.0", 2, 0 + req.Header.Set("Content-Type", "application/grpc") + req.Header.Set("TE", "trailers") + response := httptest.NewRecorder() + transport.ServeHTTP(response, req) + result := response.Result() + defer result.Body.Close() + code := result.Trailer.Get("Grpc-Status") + if code == "" { + code = result.Header.Get("Grpc-Status") + } + require.Equal(t, "12", code, "deleted RPC must be Unimplemented before authentication") + }) + } + require.Contains(t, transport.GetServiceInfo(), "saas.accounts.v1.SolutionRegistryService", "the read service must remain registered") + } +} + +func TestRegistrationDescriptorHasNoRuntimeWritersOrLease(t *testing.T) { + registry := gen.File_saas_accounts_v1_solution_registry_proto + for _, name := range []string{"SolutionFrontendBinding", "SolutionBackendBinding"} { + descriptor := registry.Messages().ByName(protoreflect.Name(name)) + require.NotNil(t, descriptor) + require.Nil(t, descriptor.Fields().ByName("lease_expires_at")) + } + for _, name := range []string{"PutSolutionRegistrationRequest", "DeleteSolutionRegistrationRequest"} { + require.Nil(t, registry.Messages().ByName(protoreflect.Name(name))) + } + require.Nil(t, registry.Enums().ByName("SolutionRegistrationStatus").Values().ByName("SOLUTION_REGISTRATION_STATUS_EXPIRED")) +} diff --git a/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go b/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go index 9fecbdf4a..0028480c3 100644 --- a/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go +++ b/module/services/accounts/code/pkg/adapters/rest_registration_catalog_gen.go @@ -125,6 +125,7 @@ var catalogRESTExactRoutes = map[string]struct{}{ "GET /v1/collection-access": {}, "GET /v1/consent/status": {}, "GET /v1/delegations:pending": {}, + "GET /v1/installations:listAvailable": {}, "GET /v1/invitations": {}, "GET /v1/mfa/devices": {}, "GET /v1/notifications": {}, diff --git a/module/services/accounts/code/pkg/adapters/solution_delivery_http.go b/module/services/accounts/code/pkg/adapters/solution_delivery_http.go new file mode 100644 index 000000000..d1158d3ff --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/solution_delivery_http.go @@ -0,0 +1,180 @@ +package adapters + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "accounts/pkg/business" + "accounts/pkg/infra" +) + +// The delivery endpoint: `POST /platform/_delivery/{presence,authority}`. +// +// Mounted on ACCOUNTS rather than brokered through the gateway, and the +// asymmetry with the credential mint is deliberate. `POST +// /platform/_credential` is brokered because a solution runtime is an +// independently deployed workload that must not reach accounts' internal +// listener. A delivery Job is not that: it is in-cluster and one of two known +// service accounts, so routing it through the edge would put accounts-audience +// tokens across the perimeter for no gain. +// +// `/platform/` is RESERVED for the host, and an unknown `/platform/*` path +// answers 404 rather than falling through. That is a contract rather than +// tidiness: a consumer has to be able to tell "no such endpoint" from "endpoint +// broken", and a fall-through to generic routing answers 502 for the first. + +// SolutionDeliveryPrefix is where the delivery endpoints are mounted. Published +// and pinned by the doc-claim gate, so a rename that does not move the document +// breaks every consumer silently. +const SolutionDeliveryPrefix = "/platform/_delivery/" + +// solutionDeliveryMaxBytes bounds a carrier body. +// +// A presence document is a few kilobytes and a Sigstore bundle is tens; 1 MiB is +// far above both and far below anything that would cost the host to read. The +// bound exists because this endpoint authenticates the caller only AFTER parsing +// enough to know what was sent, so an unbounded read would be work an +// unauthenticated caller could ask for. +const solutionDeliveryMaxBytes = 1 << 20 + +type solutionDeliveryService interface { + ReceiveSolutionDelivery( + ctx context.Context, carrier business.SolutionDeliveryCarrier, credential string, + ) (business.SolutionDeliveryDisposition, *business.SolutionDeliveryRecord, error) +} + +// SolutionDeliveryHTTPHandler serves the two delivery paths. +type SolutionDeliveryHTTPHandler struct { + service solutionDeliveryService +} + +// NewSolutionDeliveryHTTPHandler builds the handler. +func NewSolutionDeliveryHTTPHandler(service *business.Service) http.Handler { + return &SolutionDeliveryHTTPHandler{service: service} +} + +// solutionDeliveryReceipt is what a caller gets back. The disposition is echoed +// so a Job can log WHY it got the code it did — 200 and 202 are both success and +// mean different things, and a retry policy written against the code alone +// cannot tell a replay from a new generation. +type solutionDeliveryReceipt struct { + Disposition string `json:"disposition"` + DocumentID string `json:"documentId,omitempty"` + Generation uint64 `json:"generation,omitempty"` + ContentHash string `json:"contentHash,omitempty"` + Signer string `json:"signer,omitempty"` + Error string `json:"error,omitempty"` +} + +func (h *SolutionDeliveryHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // An unknown path under the reserved namespace is 404, never a fall-through. + kind := business.SolutionDeliveryKind(strings.TrimPrefix(r.URL.Path, SolutionDeliveryPrefix)) + if !strings.HasPrefix(r.URL.Path, SolutionDeliveryPrefix) || !kind.Valid() { + writeSolutionDeliveryError(w, http.StatusNotFound, "no such delivery endpoint") + return + } + if r.Method != http.MethodPost { + // 405 with Allow, so a GET probing the surface learns the method rather + // than reading a 404 as "this host does not have delivery". + w.Header().Set("Allow", http.MethodPost) + writeSolutionDeliveryError(w, http.StatusMethodNotAllowed, "delivery is a POST") + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, solutionDeliveryMaxBytes)) + if err != nil { + writeSolutionDeliveryError(w, http.StatusBadRequest, "carrier body could not be read or exceeds the bound") + return + } + + disposition, record, err := h.service.ReceiveSolutionDelivery( + r.Context(), + business.SolutionDeliveryCarrier{Kind: kind, Raw: body}, + r.Header.Get("Authorization"), + ) + if err != nil { + status, message := solutionDeliveryStatus(err) + writeSolutionDeliveryError(w, status, message) + return + } + + // 202 for a new generation now durable, 200 for an exact replay. Both are + // success; the difference is whether anything changed, which is what a + // pipeline's own idempotency depends on knowing. + status := http.StatusAccepted + if disposition == business.SolutionDeliveryReplayed { + status = http.StatusOK + } + writeSolutionDeliveryJSON(w, status, solutionDeliveryReceipt{ + Disposition: string(disposition), + DocumentID: record.DocumentID, + Generation: record.Generation, + ContentHash: record.ContentHash, + Signer: record.SignerIdentity, + }) +} + +// solutionDeliveryStatus maps a refusal onto its response code. +// +// This mapping IS the published contract, and it is written as one function so +// the taxonomy is readable in one place rather than inferred from scattered +// returns. The organising question is not severity but **what a retry would +// change**: +// +// 400 malformed terminal — the bytes are wrong +// 401 carrier refused terminal — the token is kubelet-projected and +// re-read per request, so a refusal is a +// configuration fault and a retry asks the same +// question +// 403 not the writer terminal — wrong service account or namespace +// 403 unattested terminal — no accepted signer +// 403 domain not granted terminal — accepted signer, wrong authority +// 409 rewritten generation terminal — publish never rewrites one, so this is +// a hand-edited delivery tree +// 422 invalid document terminal — it parses and Validate refuses it +// 503 cannot verify RETRYABLE — the host could not reach the API +// server or its trust root, so nothing was checked +// +// The 401/503 split is the one that matters most and the one an earlier draft +// got wrong by grouping on HTTP class: "the review ran and refused" and "the +// review could not run" are different facts with opposite retry answers, and +// they come from different branches of the same call. +func solutionDeliveryStatus(err error) (int, string) { + switch { + case errors.Is(err, business.ErrSolutionDeliveryMalformed): + return http.StatusBadRequest, err.Error() + case errors.Is(err, infra.ErrCarrierUnauthenticated): + return http.StatusUnauthorized, err.Error() + case errors.Is(err, infra.ErrCarrierNotAuthorized): + return http.StatusForbidden, err.Error() + case errors.Is(err, business.ErrSolutionDeliveryUnattested): + return http.StatusForbidden, err.Error() + case errors.Is(err, business.ErrSolutionDeliveryDomainNotGranted): + return http.StatusForbidden, err.Error() + case errors.Is(err, business.ErrSolutionDeliveryRewritten): + return http.StatusConflict, err.Error() + case errors.Is(err, business.ErrSolutionDeliveryInvalid): + return http.StatusUnprocessableEntity, err.Error() + case errors.Is(err, infra.ErrKubernetesUnavailable): + return http.StatusServiceUnavailable, err.Error() + } + // Anything unclassified is 503 rather than 500, deliberately. An unmapped + // failure means the host does not know whether it verified the carrier, and + // the honest answer to "did you accept this?" is "ask again" rather than a + // 500 that a Job's retry policy treats as terminal. + return http.StatusServiceUnavailable, "delivery could not be completed" +} + +func writeSolutionDeliveryError(w http.ResponseWriter, status int, message string) { + writeSolutionDeliveryJSON(w, status, solutionDeliveryReceipt{Error: message}) +} + +func writeSolutionDeliveryJSON(w http.ResponseWriter, status int, body solutionDeliveryReceipt) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go b/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go new file mode 100644 index 000000000..7b9534970 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/solution_delivery_http_test.go @@ -0,0 +1,415 @@ +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "accounts/pkg/business" + "accounts/pkg/infra" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// The delivery endpoint's PUBLISHED contract, exercised through the handler. +// +// The response-code table in `solution_delivery_http.go` is what three +// repositories agreed on, and a table in a comment is a claim until something +// drives the handler and reads the codes back. These requests go through the +// real `ReceiveSolutionDelivery` with fake collaborators, so the ordering and +// the mapping are both under test rather than the mapping alone. +// +// THE ONE DISTINCTION THESE TESTS EXIST FOR is 401 versus 503: "the review ran +// and refused" against "the review could not run". They are different facts with +// opposite retry answers, and an earlier draft of the mapping got it wrong by +// grouping on HTTP class. A test that only checked "it is an error" would have +// passed against that draft. + +type fakeDeliveryVerifier struct { + signer string + err error + // payloads records what was verified, so "the bundle is checked over + // exactly these bytes" is observable rather than assumed. + payloads [][]byte +} + +func (f *fakeDeliveryVerifier) VerifyBundle( + _ context.Context, payload []byte, _ json.RawMessage, +) (string, error) { + f.payloads = append(f.payloads, payload) + if f.err != nil { + return "", f.err + } + return f.signer, nil +} + +type fakeDeliveryStore struct { + inserted bool + err error + calls int +} + +func (f *fakeDeliveryStore) RecordDeliveredDocument( + _ context.Context, _ *business.SolutionDeliveryRecord, +) (bool, error) { + f.calls++ + if f.err != nil { + return false, f.err + } + return f.inserted, nil +} + +func (f *fakeDeliveryStore) ListNewestDeliveredDocuments( + context.Context, business.SolutionDeliveryKind, +) ([]*business.SolutionDeliveryRecord, error) { + return nil, nil +} + +type fakeCarrierAuthorizer struct { + err error + // seenNamespaces records what the document declared, because a presence + // carrier is authorised against the namespaces in the SIGNED document and + // not against host configuration. + seenNamespaces []string + calls int +} + +func (f *fakeCarrierAuthorizer) AuthorizeCarrier( + _ context.Context, _ string, _ business.SolutionDeliveryKind, declared []string, +) error { + f.calls++ + f.seenNamespaces = declared + return f.err +} + +// deliveryHarness wires a service whose delivery half is fakes. +type deliveryHarness struct { + handler http.Handler + verifier *fakeDeliveryVerifier + store *fakeDeliveryStore + carrier *fakeCarrierAuthorizer +} + +func newDeliveryHarness(t *testing.T, signer string, domains map[string][]string) *deliveryHarness { + t.Helper() + harness := &deliveryHarness{ + verifier: &fakeDeliveryVerifier{signer: signer}, + store: &fakeDeliveryStore{inserted: true}, + carrier: &fakeCarrierAuthorizer{}, + } + service, err := business.NewService(deliveryNoopStore{}) + require.NoError(t, err) + service.SetSolutionDelivery(harness.verifier, harness.store, harness.carrier, domains) + harness.handler = NewSolutionDeliveryHTTPHandler(service) + return harness +} + +func (h *deliveryHarness) post(path string, body []byte) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(body))) + request.Header.Set("Authorization", "Bearer projected-token") + recorder := httptest.NewRecorder() + h.handler.ServeHTTP(recorder, request) + return recorder +} + +// --------------------------------------------------------------------------- +// The surface itself +// --------------------------------------------------------------------------- + +// An unknown path under the reserved namespace is 404, never a fall-through to +// some other handler. +func TestDeliveryUnknownKindIs404(t *testing.T) { + harness := newDeliveryHarness(t, "signer", nil) + for _, path := range []string{ + SolutionDeliveryPrefix + "nonsense", + SolutionDeliveryPrefix, + SolutionDeliveryPrefix + "presence/extra", + } { + recorder := harness.post(path, []byte("{}")) + require.Equal(t, http.StatusNotFound, recorder.Code, "path %s", path) + } + require.Zero(t, harness.verifier.payloads, "an unknown kind must not reach verification") +} + +// A GET gets 405 WITH Allow, so a probe learns the method rather than reading a +// 404 as "this host has no delivery endpoint at all". +func TestDeliveryGetIs405WithAllow(t *testing.T) { + harness := newDeliveryHarness(t, "signer", nil) + request := httptest.NewRequest(http.MethodGet, SolutionDeliveryPrefix+"presence", nil) + recorder := httptest.NewRecorder() + harness.handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusMethodNotAllowed, recorder.Code) + require.Equal(t, http.MethodPost, recorder.Header().Get("Allow"), + "without Allow, a caller cannot tell a wrong method from a missing endpoint") +} + +// A body past the bound is 400 and never buffered whole. +func TestDeliveryOversizedBodyIs400(t *testing.T) { + harness := newDeliveryHarness(t, "signer", nil) + recorder := harness.post(SolutionDeliveryPrefix+"presence", + []byte(strings.Repeat("a", (1<<20)+1))) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, harness.verifier.payloads, "an oversized body must not reach verification") +} + +// --------------------------------------------------------------------------- +// The code table, refusal by refusal +// --------------------------------------------------------------------------- + +// Malformed bytes are 400: terminal, because the bytes are wrong. +func TestDeliveryMalformedCarrierIs400(t *testing.T) { + harness := newDeliveryHarness(t, "signer", nil) + recorder := harness.post(SolutionDeliveryPrefix+"presence", []byte("not a carrier")) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, harness.carrier.calls, + "a carrier that does not parse is refused before the caller is authorised") +} + +// THE SPLIT THAT MATTERS. A carrier the host refused is 401 — terminal, because +// the token is kubelet-projected and re-read per request, so a refusal is a +// configuration fault and a retry asks the same question. A carrier the host +// could not CHECK is 503 — retryable, because nothing was decided. +// +// Both in one test, because either alone is satisfied by collapsing them, which +// is exactly the mistake the mapping's comment records. +func TestCarrierRefusedIs401WhileUnreachableIs503(t *testing.T) { + carrier := signedPresenceCarrier(t, "acme", "acme-prod") + + refused := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + refused.carrier.err = fmt.Errorf("%w: wrong service account", infra.ErrCarrierUnauthenticated) + recorder := refused.post(SolutionDeliveryPrefix+"presence", carrier) + require.Equal(t, http.StatusUnauthorized, recorder.Code, + "the review RAN and refused: a retry asks the same question") + + unreachable := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + unreachable.carrier.err = fmt.Errorf("%w: dial tcp: connection refused", infra.ErrKubernetesUnavailable) + recorder = unreachable.post(SolutionDeliveryPrefix+"presence", carrier) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, + "the review COULD NOT RUN: nothing was decided, so the caller must retry") +} + +// A caller in the wrong namespace or with the wrong service account is 403: +// authenticated, and not the writer. +func TestCarrierNotAuthorizedIs403(t *testing.T) { + harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + harness.carrier.err = fmt.Errorf("%w: namespace not declared", infra.ErrCarrierNotAuthorized) + + recorder := harness.post(SolutionDeliveryPrefix+"presence", + signedPresenceCarrier(t, "acme", "acme-prod")) + require.Equal(t, http.StatusForbidden, recorder.Code) +} + +// An unattested carrier is 403, and the signature is checked BEFORE the caller. +// +// That order is the contract: otherwise a captured carrier replayed by an +// authorised caller would be refused with a message about credentials, and an +// unauthenticated caller's bad signature with a message about signatures. The +// operator reads the wrong cause in both directions. +func TestUnattestedCarrierIs403AndIsRefusedBeforeTheCaller(t *testing.T) { + harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + harness.verifier.err = errors.New("no accepted signer") + + recorder := harness.post(SolutionDeliveryPrefix+"presence", + signedPresenceCarrier(t, "acme", "acme-prod")) + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Zero(t, harness.carrier.calls, + "verification precedes carrier authorisation, so the operator reads the real cause") +} + +// An accepted signer delivering under a domain it may not speak for is 403 — +// distinct from being unattested, because the signature is perfectly good. +func TestSignerOutsideItsDomainIs403(t *testing.T) { + harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"other-domain"}}) + + recorder := harness.post(SolutionDeliveryPrefix+"presence", + signedPresenceCarrier(t, "acme", "acme-prod")) + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Zero(t, harness.store.calls, "nothing is persisted for a domain the signer cannot claim") +} + +// A rewritten generation is 409: publish never rewrites one, so this is a +// hand-edited delivery tree and no retry helps. +func TestRewrittenGenerationIs409(t *testing.T) { + harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + harness.store.err = fmt.Errorf("%w: generation 3", business.ErrSolutionDeliveryRewritten) + + recorder := harness.post(SolutionDeliveryPrefix+"presence", + signedPresenceCarrier(t, "acme", "acme-prod")) + require.Equal(t, http.StatusConflict, recorder.Code) +} + +// An UNCLASSIFIED failure is 503, not 500, and this is a deliberate choice +// rather than a default. +// +// An unmapped failure means the host does not know whether it verified the +// carrier, and the honest answer to "did you accept this?" is "ask again". A 500 +// is what a Job's retry policy treats as terminal, so it would strand a delivery +// the host might well accept on the next attempt. +func TestUnclassifiedFailureIs503NotServerError(t *testing.T) { + harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + harness.store.err = errors.New("something nobody mapped") + + recorder := harness.post(SolutionDeliveryPrefix+"presence", + signedPresenceCarrier(t, "acme", "acme-prod")) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.NotEqual(t, http.StatusInternalServerError, recorder.Code, + "a 500 is terminal to a Job's retry policy, and the host does not know this is terminal") +} + +// --------------------------------------------------------------------------- +// Success, and the 202/200 split a pipeline's idempotency depends on +// --------------------------------------------------------------------------- + +// A new generation is 202 with a receipt; an exact replay is 200. Both are +// success, and the difference is whether anything CHANGED — which is what lets a +// publishing pipeline tell "I delivered this" from "this was already here". +func TestNewGenerationIs202AndAnExactReplayIs200(t *testing.T) { + carrier := signedPresenceCarrier(t, "acme", "acme-prod") + + fresh := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + fresh.store.inserted = true + recorder := fresh.post(SolutionDeliveryPrefix+"presence", carrier) + require.Equal(t, http.StatusAccepted, recorder.Code) + + // The tags are the WIRE CONTRACT, spelled here independently of the + // handler's own struct so this test pins the field names a consuming + // pipeline parses. Declared camelCase because that is what the endpoint + // emits — my first draft of this test guessed snake_case and failed, which + // is the test doing its job: a cross-repo contract that nothing spells out + // twice is a contract one side can rename silently. + var receipt struct { + Disposition string `json:"disposition"` + DocumentID string `json:"documentId"` + Generation uint64 `json:"generation"` + ContentHash string `json:"contentHash"` + Signer string `json:"signer"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &receipt)) + require.Equal(t, string(business.SolutionDeliveryAccepted), receipt.Disposition) + require.Equal(t, "signer", receipt.Signer, "the receipt names the ATTESTED signer, not the caller") + require.Regexp(t, `^sha256:[0-9a-f]{64}$`, receipt.ContentHash, + "the receipt carries the content hash a pipeline compares to decide whether to re-deliver") + require.Equal(t, uint64(3), receipt.Generation) + require.Equal(t, "acme.test.delivery-http", receipt.DocumentID) + + replay := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + replay.store.inserted = false + recorder = replay.post(SolutionDeliveryPrefix+"presence", carrier) + require.Equal(t, http.StatusOK, recorder.Code) + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &receipt)) + require.Equal(t, string(business.SolutionDeliveryReplayed), receipt.Disposition) +} + +// The bundle is verified over the carrier's PAYLOAD bytes, and the caller's +// declared namespaces come from the signed document. +// +// Both are properties the handler cannot be trusted on by inspection: a verifier +// handed the whole request body, or an authoriser handed namespaces from host +// configuration, would behave identically on the happy path. +func TestVerificationCoversThePayloadAndNamespacesComeFromTheDocument(t *testing.T) { + harness := newDeliveryHarness(t, "signer", map[string][]string{"signer": {"acme"}}) + recorder := harness.post(SolutionDeliveryPrefix+"presence", + signedPresenceCarrier(t, "acme", "acme-prod")) + require.Equal(t, http.StatusAccepted, recorder.Code) + + require.Len(t, harness.verifier.payloads, 1) + require.NotEmpty(t, harness.verifier.payloads[0]) + require.Equal(t, []string{"acme-prod"}, harness.carrier.seenNamespaces, + "the presence namespace is a property of the SIGNED document, not of host configuration") +} + +// signedPresenceCarrier builds a carrier core's ParseSigned accepts, with a +// bundle the fake verifier stands in for. +// +// Built through core's own types and `MarshalSigned` over `CanonicalBytes`, not +// a hand-written JSON literal: the signing input IS the canonical encoding, and +// a literal would drift the moment core's schema moved while still passing. +// `Validate` is asserted here so a malformed fixture fails as a fixture rather +// than as a 422 that looks like the handler refusing correctly. +func signedPresenceCarrier(t *testing.T, domain, namespace string) []byte { + t.Helper() + document := &solutionhost.SolutionHostBinding{ + Schema: solutionhost.SchemaPresenceV2, + Kind: solutionhost.KindSolution, + Binding: "acme.test.delivery-http", + Generation: 3, + OwnershipDomain: domain, + EnvelopeRevision: 1, + Host: solutionhost.HostTarget{ + Coordinate: "acme/test/eu-west-1", Component: "saas-host", + }, + Release: solutionhost.Release{ + Publisher: "acme", Name: "delivery-http", Version: "1.4.0", + Digest: solutionhost.ReleaseDigest("sha256:" + strings.Repeat("ef", 32)), + }, + Routes: []solutionhost.Route{{Alias: "delivery-http", Surface: solutionhost.SurfaceBackend}}, + Artifacts: []solutionhost.Artifact{{ + Surface: solutionhost.SurfaceBackend, + Name: "api", + Release: "acme/delivery-http@1.4.0", + Digest: solutionhost.RenderedDigest("sha256:" + strings.Repeat("ab", 32)), + }}, + Workloads: []solutionhost.Workload{{ + Name: "api", Artifact: "api", Container: "api", + Image: solutionhost.Image{ + Repository: "registry.example/acme/delivery-http", + Digest: solutionhost.ImageDigest("sha256:" + strings.Repeat("cd", 32)), + }, + // The namespace reaches the carrier authoriser from the SPIFFE ID, + // which is why there is no Namespace field to set: a workload's + // namespace is derived from its signed identity rather than + // declared beside it, so it cannot be edited without breaking the + // signature. That is the property + // TestVerificationCoversThePayloadAndNamespacesComeFromTheDocument + // asserts, and it holds because of where the value comes from. + Identity: solutionhost.WorkloadIdentity{ + Audience: "https://test.acme.example/solutions", + Subject: "system:serviceaccount:" + namespace + ":api", + SPIFFEID: "spiffe://acme.test/ns/" + namespace + "/sa/api", + }, + NonAuthenticating: &[]string{"telemetry-sidecar"}, + }}, + } + require.NoError(t, document.Validate(), "the fixture must be a valid binding") + + canonical, err := document.CanonicalBytes() + require.NoError(t, err) + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: canonical, + Bundle: json.RawMessage(solutionhost.FixtureBundle), + }) + require.NoError(t, err) + return carrier +} + +// deliveryNoopStore satisfies business.Store without implementing it. +// +// The embedded interface is nil, so any method this test does not override +// PANICS rather than returning a zero value — which is what caught that the +// persist path really does open a control-plane transaction. A stub returning +// zeroes would have let the write silently no-op and the 409 test would have +// passed for the wrong reason. +type deliveryNoopStore struct{ business.Store } + +// WithControlPlane runs the function inline. +// +// Overridden because the delivery write is wrapped in a control-plane +// transaction, and the point of these tests is the HTTP contract rather than +// transaction behaviour. Running it inline keeps the store error surfacing +// exactly as the real path surfaces it, which is what the 409 and the +// unclassified-failure cases depend on. +func (deliveryNoopStore) WithControlPlane( + ctx context.Context, fn func(ctx context.Context) error, +) error { + return fn(ctx) +} diff --git a/module/services/accounts/code/pkg/adapters/solution_entitlement_rpcs.go b/module/services/accounts/code/pkg/adapters/solution_entitlement_rpcs.go new file mode 100644 index 000000000..30518f392 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/solution_entitlement_rpcs.go @@ -0,0 +1,50 @@ +package adapters + +import ( + "context" + + "connectrpc.com/connect" + + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// SolutionEntitlementServer answers what one viewer may use inside one org +// (issue #949). Internal-tier: both the organization and the subject are request +// fields, so this is an authority oracle about an arbitrary principal and a bare +// tenant JWT must never be able to ask it. The caller that may is the +// auth-gateway, naming the tenant and viewer it projected from a verified +// identity. +type SolutionEntitlementServer struct { + gen.UnsafeSolutionEntitlementServiceServer +} + +func (s *SolutionEntitlementServer) ListSolutionEntitlements( + ctx context.Context, req *gen.ListSolutionEntitlementsRequest, +) (*gen.ListSolutionEntitlementsResponse, error) { + if err := Validate(req); err != nil { + return nil, err + } + if err := requireInternalCredential(ctx); err != nil { + return nil, err + } + resp, err := service.ListSolutionEntitlements(ctx, req) + if err != nil { + return nil, mapInstallationError(err) + } + return resp, nil +} + +var _ gen.SolutionEntitlementServiceServer = (*SolutionEntitlementServer)(nil) + +// solutionEntitlementConnectHandler wraps the gRPC server so the same guard and +// handler body run for a Connect request. Catalog generation owns registration +// through the shared singleton binding. +type solutionEntitlementConnectHandler struct { + inner *SolutionEntitlementServer +} + +func (h *solutionEntitlementConnectHandler) ListSolutionEntitlements( + ctx context.Context, req *connect.Request[gen.ListSolutionEntitlementsRequest], +) (*connect.Response[gen.ListSolutionEntitlementsResponse], error) { + return unary(ctx, req, h.inner.ListSolutionEntitlements) +} diff --git a/module/services/accounts/code/pkg/adapters/solution_registry_boundary_test.go b/module/services/accounts/code/pkg/adapters/solution_registry_boundary_test.go index cdfd3aec5..ff6488f33 100644 --- a/module/services/accounts/code/pkg/adapters/solution_registry_boundary_test.go +++ b/module/services/accounts/code/pkg/adapters/solution_registry_boundary_test.go @@ -2,6 +2,7 @@ package adapters import ( "context" + "strings" "testing" "time" @@ -21,12 +22,11 @@ import ( // accounts derives and seals the boundary from the credential the solution // already presents. // -// This drives the registry server with a store that DOES hold a boundary, so -// the assertion is about what accounts chooses to send rather than about a fake -// that cleared the field itself. Making solutionRegistrationProto set -// RuntimeBoundary fails every case below. - -const boundaryStoredSeed = "019f6c02-cccc-7ccc-8ccc-cccccccccc03" +// The assertion below is over the wire DESCRIPTOR, so it does not depend on a +// store holding a seed — and since migration 28 no store can. `business. +// SolutionRegistration` has no boundary field and `solution_registrations` has no +// boundary column, so there is no value for a projection to leak by accident; +// what this guards is a later change reintroducing the FIELD. // boundaryRegistryStore is the registry the server reads, holding one record // with a seed. Only the registry methods are implemented; every other Store @@ -55,9 +55,6 @@ func (s *boundaryRegistryStore) NextSolutionRegistryRevision(_ context.Context) func (s *boundaryRegistryStore) SaveSolutionRegistration( _ context.Context, record *business.SolutionRegistration, ) error { - // The relation reports back whatever it holds, which is how the seed stays - // the host's. Mirror that: a save never loses it. - record.RuntimeBoundary = boundaryStoredSeed s.saved = record return nil } @@ -70,18 +67,16 @@ func (s *boundaryRegistryStore) ListSolutionRegistrations( func newBoundaryRegistryService(t *testing.T) *boundaryRegistryStore { t.Helper() - lease := time.Now().UTC().Add(time.Minute) store := &boundaryRegistryStore{record: &business.SolutionRegistration{ - SolutionID: "example-solution", - Publisher: "solution:example-solution", - Revision: 7, - RuntimeBoundary: boundaryStoredSeed, + SolutionID: "example-solution", + Publisher: "solution:example-solution", + Revision: 7, Frontend: &business.SolutionFrontendHalf{ - Revision: 7, Manifest: `{"id":"example-solution"}`, LeaseExpiresAt: lease, + Revision: 7, Manifest: `{"id":"example-solution"}`, }, Backend: &business.SolutionBackendHalf{ Revision: 7, Upstream: "http://upstream.example:8080", - ServiceAlias: "example-solution", LeaseExpiresAt: lease, + ServiceAlias: "example-solution", }, UpdatedAt: time.Now().UTC(), }} @@ -93,59 +88,37 @@ func newBoundaryRegistryService(t *testing.T) *boundaryRegistryStore { return store } +// The registry's responses carry no runtime boundary, and on this branch that +// is STRUCTURAL rather than a value that happens to be empty. +// +// It used to assert `GetRuntimeBoundary()` was empty on a listing, on a +// deregistration and on each half's own write. Three of those four surfaces are +// gone with the runtime registration writer — `PutSolutionRegistration` and +// `DeleteSolutionRegistration` are deleted RPCs, and +// TestDeletedRegistrationRPCsAreUnimplemented pins them as such. The listing +// survives, because the declared registry still answers it, and it is the +// surface that mattered most anyway: a consumer that caches the registry would +// hold every solution's seed. +// +// The assertion is over the DESCRIPTOR, not a value. The wire message has no +// `runtime_boundary` field at all, so there is nothing a later change could +// populate — and "the field does not exist" is a guarantee an empty string is +// not. func TestSolutionRegistrationResponsesCarryNoRuntimeBoundary(t *testing.T) { - store := newBoundaryRegistryService(t) + newBoundaryRegistryService(t) server := SolutionRegistrySingleton() - t.Run("the whole-registry listing", func(t *testing.T) { - out, err := server.ListSolutionRegistrations( - context.Background(), &gen.ListSolutionRegistrationsRequest{IncludeTombstoned: true}) - require.NoError(t, err) - require.Len(t, out.GetRegistrations(), 1) - require.Empty(t, out.GetRegistrations()[0].GetRuntimeBoundary(), - "a consumer that caches the registry would hold every solution's seed") - }) - - t.Run("a deregistration", func(t *testing.T) { - out, err := server.DeleteSolutionRegistration( - context.Background(), &gen.DeleteSolutionRegistrationRequest{SolutionId: "example-solution"}) - require.NoError(t, err) - require.Empty(t, out.GetRuntimeBoundary()) - // The store still holds it: the tombstone keeps the seed so a - // reactivated registration keeps naming the runs it already admitted. - require.Equal(t, boundaryStoredSeed, store.saved.RuntimeBoundary) - }) - - // Both halves' own writes, including the backend half — the one that mints. - // Even that registrant is not told: accounts seals the boundary from the - // credential, so nothing in a solution has a use for the seed. - for name, half := range map[string]*gen.PutSolutionRegistrationRequest{ - "the frontend half's own write": { - Half: &gen.PutSolutionRegistrationRequest_Frontend{ - Frontend: &gen.SolutionFrontendRegistration{Manifest: `{"id":"example-solution"}`}, - }, - }, - "the backend half's own write": { - Half: &gen.PutSolutionRegistrationRequest_Backend{ - Backend: &gen.SolutionBackendRegistration{ - Upstream: "http://upstream.example:8080", - ServiceAlias: "example-solution", - }, - }, - }, - } { - t.Run(name, func(t *testing.T) { - store := newBoundaryRegistryService(t) - server := SolutionRegistrySingleton() - request := half - request.SolutionId = "example-solution" - request.Publisher = "solution:example-solution" - request.LeaseSeconds = 120 - - out, err := server.PutSolutionRegistration(context.Background(), request) - require.NoError(t, err) - require.Empty(t, out.GetRuntimeBoundary()) - require.Equal(t, boundaryStoredSeed, store.saved.RuntimeBoundary) - }) + out, err := server.ListSolutionRegistrations( + context.Background(), &gen.ListSolutionRegistrationsRequest{IncludeTombstoned: true}) + require.NoError(t, err) + require.Len(t, out.GetRegistrations(), 1, "the listing must answer, or the assertion below is vacuous") + + fields := out.GetRegistrations()[0].ProtoReflect().Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + name := string(fields.Get(i).Name()) + require.NotContains(t, strings.ToLower(name), "boundary", + "saas.accounts.v1.SolutionRegistration carries field %q: the seed selects the boundary a "+ + "Work Context is sealed under, so a consumer that caches the registry would hold "+ + "every solution's seed", name) } } diff --git a/module/services/accounts/code/pkg/adapters/solution_registry_server.go b/module/services/accounts/code/pkg/adapters/solution_registry_server.go index 1cf174fde..78fd40ec3 100644 --- a/module/services/accounts/code/pkg/adapters/solution_registry_server.go +++ b/module/services/accounts/code/pkg/adapters/solution_registry_server.go @@ -2,23 +2,17 @@ package adapters import ( "context" - "errors" - "time" "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" "connectrpc.com/connect" - "google.golang.org/genproto/googleapis/rpc/errdetails" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) // SolutionRegistryServer serves the durable solution registry (issue #534) on // the accounts internal listener. The auth-gateway is its only client: it -// writes its own backend half, brokers the frontend's half, and reads the -// snapshot both surfaces rebuild their caches from. +// reads the snapshot both product surfaces rebuild their caches from. type SolutionRegistryServer struct { gen.UnsafeSolutionRegistryServiceServer } @@ -28,89 +22,6 @@ var solutionRegistrySingleton = &SolutionRegistryServer{} // SolutionRegistrySingleton returns the shared server instance. func SolutionRegistrySingleton() *SolutionRegistryServer { return solutionRegistrySingleton } -// solutionRegistryError maps the registry's refusals onto gRPC codes a client -// can act on: Aborted means "re-read and retry", FailedPrecondition means "your -// request cannot be made valid by retrying it as-is". -func solutionRegistryError(err error) error { - switch { - case errors.Is(err, business.ErrSolutionRegistrationNotFound): - return status.Error(codes.NotFound, "solution registration not found") - case errors.Is(err, business.ErrSolutionRegistrationStale): - return status.Error(codes.Aborted, "solution registration revision is stale") - case errors.Is(err, business.ErrSolutionRegistrationRevisionRequired): - // Aborted, not FailedPrecondition: the caller can make this write valid by - // re-reading and naming the revision. Sharing a code with the tombstone - // refusal — which must never be retried — left a client unable to tell the - // recoverable case from the permanent one, so it could retry neither. - return status.Error(codes.Aborted, "solution registration revision required") - case errors.Is(err, business.ErrSolutionRegistrationTombstoned): - return status.Error(codes.FailedPrecondition, "solution registration is tombstoned") - case errors.Is(err, business.ErrSolutionPublisherMismatch): - return status.Error(codes.PermissionDenied, "solution registration is owned by another publisher") - case errors.Is(err, business.ErrSolutionRegistrationHalfMissing): - return status.Error(codes.InvalidArgument, "solution registration must carry exactly one half") - case errors.Is(err, business.ErrSolutionRegistrationIdentityRequired): - return status.Error(codes.InvalidArgument, "solution registration requires a solution id and publisher") - case errors.Is(err, business.ErrSolutionAuditDeclarationRejected): - // InvalidArgument, not FailedPrecondition or PermissionDenied: the - // registrant must change its declaration (or the operator its - // binding), and no retry of this manifest — nor a re-read of the - // revision — will ever succeed. FailedPrecondition is already the - // tombstone refusal, which the gateway relays as a conflict to re-read. - // The message names the event and the rule it broke; the ErrorInfo - // reason is what a client keys on, since InvalidArgument alone also - // covers a malformed write. - return solutionAuditDeclarationRejected(err) - default: - return err - } -} - -func (s *SolutionRegistryServer) PutSolutionRegistration( - ctx context.Context, req *gen.PutSolutionRegistrationRequest, -) (*gen.SolutionRegistration, error) { - if err := Validate(req); err != nil { - return nil, err - } - write := business.SolutionRegistrationWrite{ - SolutionID: req.GetSolutionId(), - Publisher: req.GetPublisher(), - ExpectedRevision: req.ExpectedRevision, - Lease: time.Duration(req.GetLeaseSeconds()) * time.Second, - } - if half := req.GetFrontend(); half != nil { - write.Frontend = &business.SolutionFrontendRegistration{ - Manifest: half.GetManifest(), - ContractVersion: half.GetContractVersion(), - } - } - if half := req.GetBackend(); half != nil { - write.Backend = &business.SolutionBackendRegistration{ - Upstream: half.GetUpstream(), - ServiceAlias: half.GetServiceAlias(), - ContractVersion: half.GetContractVersion(), - } - } - record, err := service.PutSolutionRegistration(ctx, write) - if err != nil { - return nil, solutionRegistryError(err) - } - return solutionRegistrationProto(record), nil -} - -func (s *SolutionRegistryServer) DeleteSolutionRegistration( - ctx context.Context, req *gen.DeleteSolutionRegistrationRequest, -) (*gen.SolutionRegistration, error) { - if err := Validate(req); err != nil { - return nil, err - } - record, err := service.DeleteSolutionRegistration(ctx, req.GetSolutionId(), req.ExpectedRevision) - if err != nil { - return nil, solutionRegistryError(err) - } - return solutionRegistrationProto(record), nil -} - func (s *SolutionRegistryServer) ListSolutionRegistrations( ctx context.Context, req *gen.ListSolutionRegistrationsRequest, ) (*gen.ListSolutionRegistrationsResponse, error) { @@ -119,7 +30,7 @@ func (s *SolutionRegistryServer) ListSolutionRegistrations( } records, revision, err := service.ListSolutionRegistrations(ctx, req.GetIncludeTombstoned()) if err != nil { - return nil, solutionRegistryError(err) + return nil, err } out := &gen.ListSolutionRegistrationsResponse{ Registrations: make([]*gen.SolutionRegistration, 0, len(records)), @@ -134,33 +45,26 @@ func (s *SolutionRegistryServer) ListSolutionRegistrations( var solutionRegistrationStatusProto = map[business.SolutionRegistrationStatus]gen.SolutionRegistrationStatus{ business.SolutionRegistrationActive: gen.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE, business.SolutionRegistrationPending: gen.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_PENDING, - business.SolutionRegistrationExpired: gen.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_EXPIRED, business.SolutionRegistrationIncompatible: gen.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE, business.SolutionRegistrationTombstoned: gen.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_TOMBSTONED, } -// solutionRegistrationProto resolves the derived status against the server -// clock as it serializes, so a lease that lapsed since the row was written is -// reported expired rather than active. -// -// It deliberately never sets RuntimeBoundary, and NO path here ever does -// (issue #1015). Every read of the registry goes through this one function — -// the whole-registry snapshot the gateway and the frontend cache, a -// deregistration, and a registrant's own write alike — and the seed behind that -// boundary is the only thing standing between one solution's runs and another's -// now that the derived boundary is stable for the life of the registration. -// A solution does not need it: accounts derives and seals the boundary from the -// credential the solution already presents, so nothing above this service -// reads, sends or stores one. The field stays on the message so that -// TestSolutionRegistrationResponsesCarryNoRuntimeBoundary can hold every -// response to that, and a change that starts populating it fails rather than -// ships. +// solutionDeclaredKindProto maps the stored kind onto the wire. A kind the map +// does not hold projects as UNSPECIFIED, which both routing surfaces refuse by +// name: the zero value is not a kind, so a record carrying one is unroutable +// rather than routed as whichever kind needs the least authority. +var solutionDeclaredKindProto = map[business.SolutionDeclaredKind]gen.SolutionDeclaredKind{ + business.SolutionDeclaredKindSolution: gen.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION, + business.SolutionDeclaredKindModule: gen.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE, +} + +// solutionRegistrationProto serializes the derived declaration status. func solutionRegistrationProto(record *business.SolutionRegistration) *gen.SolutionRegistration { out := &gen.SolutionRegistration{ SolutionId: record.SolutionID, Publisher: record.Publisher, Revision: record.Revision, - Status: solutionRegistrationStatusProto[record.Status(time.Now().UTC())], + Status: solutionRegistrationStatusProto[record.Status()], UpdatedAt: timestamppb.New(record.UpdatedAt), } if half := record.Frontend; half != nil { @@ -168,7 +72,6 @@ func solutionRegistrationProto(record *business.SolutionRegistration) *gen.Solut Revision: half.Revision, Manifest: half.Manifest, ContractVersion: half.ContractVersion, - LeaseExpiresAt: timestamppb.New(half.LeaseExpiresAt), } } if half := record.Backend; half != nil { @@ -177,51 +80,119 @@ func solutionRegistrationProto(record *business.SolutionRegistration) *gen.Solut Upstream: half.Upstream, ServiceAlias: half.ServiceAlias, ContractVersion: half.ContractVersion, - LeaseExpiresAt: timestamppb.New(half.LeaseExpiresAt), } } if record.TombstonedAt != nil { out.TombstonedAt = timestamppb.New(*record.TombstonedAt) } + if declared := record.Declared; declared != nil { + out.Declared = &gen.SolutionDeclaredBinding{ + BindingId: declared.BindingID, + Generation: declared.Generation, + Release: declared.Release, + TargetId: declared.TargetID, + Kind: solutionDeclaredKindProto[declared.Kind], + } + } return out } -// solutionRegistryConnectHandler serves the same server over Connect, so both -// protocols enforce the same authority and see the same identity. -type solutionRegistryConnectHandler struct { - inner *SolutionRegistryServer +// ListSolutionHostBindings serves the declared-presence read surface (issue +// #952): what delivery has shown this host, what the host applied, and why a +// desired generation is not the applied one. +// +// The observed half is attached from the registry record each binding applied +// into, so one answer distinguishes a solution that was never declared from one +// declared and not reporting — which is the distinction an operator cannot draw +// from the registry snapshot alone, because a binding whose first generation was +// refused has no registry record at all. +func (s *SolutionRegistryServer) ListSolutionHostBindings( + ctx context.Context, req *gen.ListSolutionHostBindingsRequest, +) (*gen.ListSolutionHostBindingsResponse, error) { + if err := Validate(req); err != nil { + return nil, err + } + records, err := service.ListSolutionHostBindings(ctx) + if err != nil { + return nil, err + } + registrations, _, err := service.ListSolutionRegistrations(ctx, true) + if err != nil { + return nil, err + } + bySolution := make(map[string]*business.SolutionRegistration, len(registrations)) + for _, registration := range registrations { + bySolution[registration.SolutionID] = registration + } + out := &gen.ListSolutionHostBindingsResponse{ + Bindings: make([]*gen.SolutionHostBindingState, 0, len(records)), + } + for _, record := range records { + out.Bindings = append(out.Bindings, solutionHostBindingStateProto(record, bySolution)) + } + return out, nil } -func (h *solutionRegistryConnectHandler) PutSolutionRegistration(ctx context.Context, req *connect.Request[gen.PutSolutionRegistrationRequest]) (*connect.Response[gen.SolutionRegistration], error) { - return unary(ctx, req, h.inner.PutSolutionRegistration) +func solutionHostBindingStateProto( + record *business.SolutionHostBindingRecord, + bySolution map[string]*business.SolutionRegistration, +) *gen.SolutionHostBindingState { + state := &gen.SolutionHostBindingState{ + BindingId: record.BindingID, + HostCoordinate: record.HostCoordinate, + HostComponent: record.HostComponent, + PendingGeneration: record.PendingGeneration(), + PendingReason: record.PendingReason, + UpdatedAt: timestamppb.New(record.UpdatedAt), + } + if record.PendingSince != nil { + state.PendingSince = timestamppb.New(*record.PendingSince) + } + if desired := record.Desired; desired != nil { + state.Desired = solutionHostBindingGenerationProto(*desired) + } + if applied := record.Applied; applied != nil { + state.Applied = &gen.SolutionHostBindingAppliedGeneration{ + Generation: solutionHostBindingGenerationProto(applied.SolutionHostBindingGeneration), + Removed: applied.Removed, + Routes: applied.Routes, + SolutionId: applied.SolutionID, + Release: applied.Release, + } + // The observed half. A binding whose applied generation is a tombstone + // still names the record it withdrew, and that record's tombstone is the + // answer "declared absent" rather than "never registered". + if registration, found := bySolution[applied.SolutionID]; found { + state.Registration = solutionRegistrationProto(registration) + } + } + return state } -func (h *solutionRegistryConnectHandler) DeleteSolutionRegistration(ctx context.Context, req *connect.Request[gen.DeleteSolutionRegistrationRequest]) (*connect.Response[gen.SolutionRegistration], error) { - return unary(ctx, req, h.inner.DeleteSolutionRegistration) +func solutionHostBindingGenerationProto( + generation business.SolutionHostBindingGeneration, +) *gen.SolutionHostBindingGeneration { + return &gen.SolutionHostBindingGeneration{ + Generation: generation.Generation, + Digest: generation.Digest, + Document: generation.Document, + At: timestamppb.New(generation.At), + } +} + +// solutionRegistryConnectHandler serves the same server over Connect, so both +// protocols enforce the same authority and see the same identity. +type solutionRegistryConnectHandler struct { + inner *SolutionRegistryServer } func (h *solutionRegistryConnectHandler) ListSolutionRegistrations(ctx context.Context, req *connect.Request[gen.ListSolutionRegistrationsRequest]) (*connect.Response[gen.ListSolutionRegistrationsResponse], error) { return unary(ctx, req, h.inner.ListSolutionRegistrations) } -// SolutionAuditDeclarationRejectedReason is the google.rpc.ErrorInfo reason a -// refused audit event declaration carries, under SolutionRegistryErrorDomain. -// The auth-gateway keys its 422 on it — it is the stable signal, where the -// message is prose for the registrant — so it is a wire contract: the gateway -// holds the same two strings, and a test on each side pins them. -const ( - SolutionAuditDeclarationRejectedReason = "SOLUTION_AUDIT_DECLARATION_REJECTED" - SolutionRegistryErrorDomain = "accounts.saas.codefly.dev" -) - -func solutionAuditDeclarationRejected(err error) error { - rejected := status.New(codes.InvalidArgument, err.Error()) - detailed, detailErr := rejected.WithDetails(&errdetails.ErrorInfo{ - Reason: SolutionAuditDeclarationRejectedReason, - Domain: SolutionRegistryErrorDomain, - }) - if detailErr != nil { - return rejected.Err() - } - return detailed.Err() +func (h *solutionRegistryConnectHandler) ListSolutionHostBindings(ctx context.Context, req *connect.Request[gen.ListSolutionHostBindingsRequest]) (*connect.Response[gen.ListSolutionHostBindingsResponse], error) { + return unary(ctx, req, h.inner.ListSolutionHostBindings) } + +// SolutionRegistryErrorDomain is also the source-delegation ErrorInfo domain. +const SolutionRegistryErrorDomain = "accounts.saas.codefly.dev" diff --git a/module/services/accounts/code/pkg/adapters/solution_registry_server_test.go b/module/services/accounts/code/pkg/adapters/solution_registry_server_test.go deleted file mode 100644 index 8307ac6fe..000000000 --- a/module/services/accounts/code/pkg/adapters/solution_registry_server_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package adapters - -import ( - "fmt" - "strings" - "testing" - - "accounts/pkg/business" - - "google.golang.org/genproto/googleapis/rpc/errdetails" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// A client has to be able to tell a refusal it can recover from by re-reading -// apart from one it must never retry. While these two shared a code, the -// auth-gateway could distinguish neither and so retried neither, and a -// registration change reaching a replica with a cold cache failed until that -// replica's next reconcile tick. -func TestSolutionRegistryErrorSeparatesRecoverableFromPermanent(t *testing.T) { - recoverable := status.Code(solutionRegistryError(business.ErrSolutionRegistrationRevisionRequired)) - permanent := status.Code(solutionRegistryError(business.ErrSolutionRegistrationTombstoned)) - - if recoverable != codes.Aborted { - t.Fatalf("revision required = %v, want Aborted (re-read and retry)", recoverable) - } - if permanent != codes.FailedPrecondition { - t.Fatalf("tombstoned = %v, want FailedPrecondition (never retry)", permanent) - } - if recoverable == permanent { - t.Fatal("a recoverable refusal and a permanent one must not share a code") - } -} - -// A refused audit event declaration is permanent for that manifest: the -// registrant must change what it declares, so it is InvalidArgument — which the -// gateway relays as 400 — never a code that invites a retry or reads as a -// publisher conflict. The message says which rule the declaration broke. -func TestSolutionRegistryErrorRejectsAuditDeclarationAsInvalid(t *testing.T) { - _, err := business.ParseDeclaredAuditEventTypes("acme", - `{"dashboard":{"events":[{"name":"e","type":"saas.item.created","fields":[]}]}}`) - mapped := solutionRegistryError(err) - if status.Code(mapped) != codes.InvalidArgument { - t.Fatalf("rejected declaration = %v, want InvalidArgument", status.Code(mapped)) - } - if msg := status.Convert(mapped).Message(); !strings.Contains(msg, "reserved namespace") { - t.Fatalf("message %q does not name the broken rule", msg) - } - owned := fmt.Errorf("%w: %w: namespace held", business.ErrSolutionAuditDeclarationRejected, business.ErrSolutionAuditNamespaceOwned) - if status.Code(solutionRegistryError(owned)) != codes.InvalidArgument { - t.Fatal("a namespace held by another producer must be InvalidArgument too") - } - // The stable signal a client keys on is the ErrorInfo reason, which only a - // declaration refusal carries. - if !carriesDeclarationRejection(mapped) { - t.Fatalf("rejected declaration carries no %s ErrorInfo", SolutionAuditDeclarationRejectedReason) - } - if carriesDeclarationRejection(solutionRegistryError(business.ErrSolutionRegistrationHalfMissing)) { - t.Fatal("a malformed write must not carry the declaration-rejection reason") - } -} - -// The reason and domain are a wire contract with the auth-gateway, which holds -// the same two strings. -func TestSolutionAuditDeclarationRejectedReasonIsPinned(t *testing.T) { - if SolutionAuditDeclarationRejectedReason != "SOLUTION_AUDIT_DECLARATION_REJECTED" || - SolutionRegistryErrorDomain != "accounts.saas.codefly.dev" { - t.Fatal("the declaration-rejection ErrorInfo moved; move the auth-gateway's copy with it") - } -} - -func carriesDeclarationRejection(err error) bool { - for _, detail := range status.Convert(err).Details() { - if info, ok := detail.(*errdetails.ErrorInfo); ok && - info.GetReason() == SolutionAuditDeclarationRejectedReason && info.GetDomain() == SolutionRegistryErrorDomain { - return true - } - } - return false -} diff --git a/module/services/accounts/code/pkg/adapters/source_delegation.go b/module/services/accounts/code/pkg/adapters/source_delegation.go index f06f15a7e..e13f53ff5 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation.go @@ -131,7 +131,12 @@ func (s *WorkContextAuthorityServer) StartSourceOperationTask( if s == nil || s.configureErr != nil || s.signer == nil { return workcontext.WorkContextToken{}, nil, ErrWorkContextAuthorityUnconfigured } - if authority.Audience == "" || authority.Audience == ModuleWorkContextAudience { + // The shared mint rule (requireMintableAudience): empty is refused because a + // capability with no audience is good at whichever consumer accepts it, and + // the capability surface's own audience is refused because a token carrying + // it is read as a module identity. Stated once so it cannot lapse here while + // holding elsewhere. + if requireMintableAudience(authority.Audience) != nil { return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: source operation context audience", workcontext.ErrWorkContextInvalid) } if authority.Revision == 0 || authority.OwnerPrincipalID == "" || authority.OwnerPrincipalID == authority.PrincipalID { diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_test.go index 57fc3613a..c5cc20b58 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_test.go @@ -36,12 +36,12 @@ const ( // cross_tenant, whose "source-sync" binding accepts source delegations — the // delegation alone admits the delegation's organization — and a second module // without one. -const delegationPrincipals = `{"docstore":{"tenant":"` + moduleWorkContextTenant + `","operation_audiences":{` + +const delegationPrincipals = `{"docstore":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleWorkContextTenant + `","operation_audiences":{` + `"source-sync":{"audience":"ingestservice",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + `"source_delegation_scopes":[{"resource_kind":"collections","actions":["write"]}]}}},` + - `"reports":{"tenant":"` + delegationOrg + `"}}` + `"reports":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + delegationOrg + `"}}` type sourceDelegationMemoryStore struct { business.Store @@ -144,6 +144,7 @@ func installSourceDelegationService(t *testing.T) (*sourceDelegationMemoryStore, ",reports:" + registrationDigest("reports-secret")) require.NoError(t, err) svc.SetModuleIdentitySecrets(secrets) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, registry) audit := &validatingAudit{} svc.SetAuditEmitter(audit) @@ -389,6 +390,7 @@ func TestDelegatedOperationExchangeWithoutADelegationHopKeepsTheTenantCheck(t *t grant := registry[business.ModulePrincipalID("example")] grant.Tenant, grant.CrossTenant = moduleWorkContextTenant, crossTenant registry[business.ModulePrincipalID("example")] = grant + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(nil, nil, registry) module, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("example"), Tenant: moduleWorkContextTenant}) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/adapters/work_context_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_audience_test.go new file mode 100644 index 000000000..4a1c25c00 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_audience_test.go @@ -0,0 +1,350 @@ +package adapters + +import ( + "context" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// hostAudienceStore answers the one read the host's audience vocabulary is +// derived from. Every other Store method is the embedded nil interface and would +// panic if reached, which is the point: the vocabulary must come from the declared +// registry and from nothing else. +type hostAudienceStore struct { + business.Store + bindings []string + err error +} + +func (s *hostAudienceStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *hostAudienceStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + return s.bindings, s.err +} + +// withHostAudiences installs a service whose vocabulary is exactly these declared +// bindings, and returns the store so a test can make the read fail. +func withHostAudiences(t *testing.T, bindings ...string) *hostAudienceStore { + t.Helper() + store := &hostAudienceStore{bindings: bindings} + svc, err := business.NewService(store) + require.NoError(t, err) + previous := service + service = svc + t.Cleanup(func() { service = previous }) + return store +} + +// Every mint runs one audience rule, and it refuses BY NAME (issue #952). +// +// An audience is what makes two capabilities signed by one key +// non-interchangeable. An empty one was legal on every caller-supplied mint, and +// the verifier could not make up the difference: sdk-go's expectation check +// treats an empty EXPECTED audience as "do not check" +// (workcontext/work_context.go:570), so a verifier site with no value to pass and +// a token with no value to compare agreed vacuously. The host's half of the fix +// is to stamp a value always, which is what makes that sentinel unreachable from +// here. +func TestAMintAudienceIsRequiredAndRefusedByName(t *testing.T) { + for _, tc := range []struct { + name string + audience string + code codes.Code + says string + }{ + {"empty", "", codes.InvalidArgument, "audience is required"}, + {"blank", " ", codes.InvalidArgument, "audience is required"}, + {"the capability surface itself", ModuleWorkContextAudience, codes.PermissionDenied, ModuleWorkContextAudience}, + } { + t.Run(tc.name, func(t *testing.T) { + err := requireMintableAudience(tc.audience) + require.Error(t, err) + require.Equal(t, tc.code, status.Code(err), + "the two refusals are different facts and must not share a code") + require.Contains(t, err.Error(), tc.says, + "a refusal that does not name what it refused sends an operator to the wrong place") + }) + } +} + +// A whitespace-only audience is not a value. It is the shape a caller lands on by +// templating an empty variable, and treating it as present would stamp a token +// whose audience no consumer can match while reading as "has one". +func TestAnAudienceThatIsOnlyWhitespaceIsNotAnAudience(t *testing.T) { + require.Error(t, requireMintableAudience("\t\n ")) + require.NoError(t, requireMintableAudience("example-consumer"), + "an ordinary consumer audience must still pass, or the rule refuses everything") +} + +// The closed set is DERIVED from delivered presence, and a mint refuses anything +// outside it BY NAME (issue #952). +// +// Both members of the set are exercised, and the refusals are checked for what +// they say: the whole failure mode this replaces was a free-text audience nobody +// could trace to a consumer, so a refusal that does not name the value is not a +// fix. +func TestTheMintAudienceVocabularyIsClosedAndDerived(t *testing.T) { + withHostAudiences(t, "acme.test.example", "acme.test.other") + ctx := context.Background() + + t.Run("a declared binding's audience is in the set", func(t *testing.T) { + require.NoError(t, requireVocabularyAudience(ctx, business.SolutionAudience("acme.test.example"))) + require.NoError(t, requireVocabularyAudience(ctx, business.SolutionAudience("acme.test.other"))) + }) + t.Run("free text is refused and named", func(t *testing.T) { + err := requireVocabularyAudience(ctx, "example-consumer") + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), `"example-consumer"`) + }) + t.Run("a binding id without the prefix is not an audience", func(t *testing.T) { + // The prefix is what keeps a solution audience from colliding with the + // module one or with a future kind's, so the bare binding id must not pass. + err := requireVocabularyAudience(ctx, "acme.test.example") + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) + t.Run("an undeclared binding's audience is refused", func(t *testing.T) { + err := requireVocabularyAudience(ctx, business.SolutionAudience("acme.test.never-declared")) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) + t.Run("the refusal names no binding and no cardinality", func(t *testing.T) { + // It names the SHAPE and nothing else. Listing the members would report + // every solution this deployment runs, and COUNTING them reports how many + // — the same secret at lower resolution, and one a caller can watch change + // by repeating the probe. TestTheRefusalCarriesNoCardinality holds the + // count half across set sizes. + err := requireVocabularyAudience(ctx, "example-consumer") + require.NotContains(t, err.Error(), "acme.test.example") + require.NotContains(t, err.Error(), "acme.test.other") + require.Contains(t, err.Error(), business.SolutionAudiencePrefix+"") + }) +} + +// The module capability surface's audience is IN the set and still never +// caller-supplied. +// +// Two rules that would be easy to collapse into one and must not be. It is in the +// vocabulary because it names a real consumer — the surface a module's own +// capability is good for — and it is refused on a caller-supplied mint because a +// context addressed to it is read as a MODULE IDENTITY. Membership and +// mintability are different questions about the same string. +func TestTheModuleAudienceIsInTheSetAndNeverCallerSupplied(t *testing.T) { + withHostAudiences(t, "acme.test.example") + set, err := service.HostAudiences(context.Background()) + require.NoError(t, err) + require.Contains(t, set, business.ModuleCapabilitiesAudience, "the module audience is a member of the vocabulary") + + err = requireVocabularyAudience(context.Background(), ModuleWorkContextAudience) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "module identity", + "the refusal must say why, or it reads as the audience simply being unknown") +} + +// The adapters' constant and the vocabulary's are ONE audience. +// +// Two spellings of one audience is exactly the drift the closed set exists to +// prevent, and it would be invisible: the set would contain one string and the +// mint would refuse the other, so every module mint would fail with "not an +// audience this host serves" while the constant looked right in both files. +func TestTheModuleAudienceConstantsAreOneValue(t *testing.T) { + require.Equal(t, business.ModuleCapabilitiesAudience, ModuleWorkContextAudience) +} + +// A vocabulary that cannot be RESOLVED fails the mint closed, and as Unavailable +// rather than PermissionDenied. +// +// The distinction is the operator's: PermissionDenied sends them to look at a +// grant, Unavailable to look at the registry. A set that cannot be read must read +// as neither "no audience is valid" nor "any is". +func TestAnUnresolvableVocabularyFailsTheMintClosed(t *testing.T) { + store := withHostAudiences(t, "acme.test.example") + store.err = context.DeadlineExceeded + err := requireVocabularyAudience(context.Background(), business.SolutionAudience("acme.test.example")) + require.Equal(t, codes.Unavailable, status.Code(err), + "an audience the set WOULD contain must still be refused when the set cannot be read") +} + +// NO HOST SITE EVER PASSES AN EMPTY AUDIENCE — the test the ruling asked for, and +// the reason sdk-go's `""`-means-skip sentinel is safe to leave for the cutover. +// +// The sentinel is `if check.want != "" && check.got != check.want` in +// sdk-go's workcontext/work_context.go: an empty EXPECTED audience means "do not +// check". That is not this repo's to delete. What this repo can guarantee is that +// the expectation is never empty, which makes the sentinel unreachable from here. +// +// Asserted over the SET, not over one call: every member of the host's vocabulary +// is non-empty, and every mint path refuses an empty audience before it reaches a +// signer. A mutation that lets one through fails here. +func TestNoHostAudienceIsEverEmpty(t *testing.T) { + withHostAudiences(t, "acme.test.example", "", " ") + set, err := service.HostAudiences(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, set) + for audience := range set { + require.NotEmpty(t, audience, + "an empty member would make sdk-go's empty-expectation sentinel reachable: an empty expected audience is not checked at all") + require.Equal(t, audience, string([]rune(audience)), "members are compared by value, so they must be exact") + } + // A blank binding id contributes NO audience rather than the bare prefix: a + // "solution:" with nothing after it would be a member that names no binding. + require.NotContains(t, set, business.SolutionAudiencePrefix) + require.Len(t, set, 2, "one module audience and one real binding; the blank and whitespace ids contribute nothing") + + // And the structural half refuses an empty audience without consulting the set + // at all, so it holds even when the registry cannot be read. + require.Error(t, requireMintableAudience("")) + require.Error(t, requireMintableAudience(" ")) +} + +// countingAudienceStore records how many control-plane reads the vocabulary makes, +// which is the only way to prove the mint is off the hot path: a passing mint says +// nothing about how many transactions it opened. +type countingAudienceStore struct { + hostAudienceStore + reads int +} + +func (s *countingAudienceStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + s.reads++ + return s.bindings, s.err +} + +func withCountingHostAudiences(t *testing.T, bindings ...string) *countingAudienceStore { + t.Helper() + store := &countingAudienceStore{hostAudienceStore: hostAudienceStore{bindings: bindings}} + svc, err := business.NewService(store) + require.NoError(t, err) + previous := service + service = svc + t.Cleanup(func() { service = previous }) + return store +} + +// The vocabulary is read from a BOUNDED SNAPSHOT, not on every call. +// +// It used to open a control-plane transaction per call — twice per mint, since the +// mint checks the audience and then narrows the actor's ceiling — on a pool whose +// MaxConns is the pgx default and which every other control-plane capability +// shares, the delivery reconciler among them. A mint storm therefore queued +// delivery: the mechanism that withdraws a compromised solution. +// +// Asserted on the READ COUNT, because that is the defect. A latency assertion +// would be flaky and a passing mint proves nothing about transactions opened. +func TestTheAudienceVocabularyIsReadFromABoundedSnapshot(t *testing.T) { + store := withCountingHostAudiences(t, "acme.test.example") + ctx := context.Background() + audience := business.SolutionAudience("acme.test.example") + + require.NoError(t, requireVocabularyAudience(ctx, audience)) + require.Equal(t, 1, store.reads, "the first resolution reads") + + for range 50 { + require.NoError(t, requireVocabularyAudience(ctx, audience)) + _, err := service.LiveHostAudiences(ctx, []string{audience}) + require.NoError(t, err) + } + require.Equal(t, 1, store.reads, + "a hundred further resolutions inside the bound must open no further control-plane transaction") +} + +// The bound EXPIRES, so another replica's withdrawal reaches this one. +// +// Proved by moving the clock rather than by sleeping it out: a bound no test can +// move is a bound asserted by its constant instead of by its behaviour. +func TestTheAudienceSnapshotExpiresAtTheBound(t *testing.T) { + store := withCountingHostAudiences(t, "acme.test.example") + now := time.Now() + service.SetAudienceClockForTest(func() time.Time { return now }) + ctx := context.Background() + audience := business.SolutionAudience("acme.test.example") + + require.NoError(t, requireVocabularyAudience(ctx, audience)) + require.Equal(t, 1, store.reads) + + now = now.Add(business.HostAudienceCacheBound - time.Second) + require.NoError(t, requireVocabularyAudience(ctx, audience)) + require.Equal(t, 1, store.reads, "inside the bound the snapshot still serves") + + now = now.Add(2 * time.Second) + require.NoError(t, requireVocabularyAudience(ctx, audience)) + require.Equal(t, 2, store.reads, "past the bound it re-reads") + + // And the re-read is what carries another replica's withdrawal: the same + // audience is refused once the registry no longer holds the binding. + store.bindings = nil + now = now.Add(business.HostAudienceCacheBound + time.Second) + err := requireVocabularyAudience(ctx, audience) + require.Equal(t, codes.PermissionDenied, status.Code(err), + "a withdrawal another replica applied must reach this one at the bound") +} + +// A FAILED read does not install an empty snapshot. +// +// This is the direction that would be catastrophic and quiet: an outage during a +// refresh installing "this host serves no solutions" would refuse every solution +// mint for a whole bound, and look exactly like a vocabulary decision. +func TestAFailedVocabularyReadDoesNotPoisonTheSnapshot(t *testing.T) { + store := withCountingHostAudiences(t, "acme.test.example") + now := time.Now() + service.SetAudienceClockForTest(func() time.Time { return now }) + ctx := context.Background() + audience := business.SolutionAudience("acme.test.example") + require.NoError(t, requireVocabularyAudience(ctx, audience)) + + now = now.Add(business.HostAudienceCacheBound + time.Second) + store.err = context.DeadlineExceeded + require.Equal(t, codes.Unavailable, status.Code(requireVocabularyAudience(ctx, audience)), + "the refresh failed, so the mint fails closed") + + store.err = nil + require.NoError(t, requireVocabularyAudience(ctx, audience), + "and once the read recovers the audience is served again, not refused from a poisoned snapshot") +} + +// Invalidation makes THIS replica's withdrawal visible to the very next mint, +// without waiting out the bound. +func TestInvalidationMakesAWithdrawalVisibleAtOnce(t *testing.T) { + store := withCountingHostAudiences(t, "acme.test.example") + now := time.Now() + service.SetAudienceClockForTest(func() time.Time { return now }) + ctx := context.Background() + audience := business.SolutionAudience("acme.test.example") + require.NoError(t, requireVocabularyAudience(ctx, audience)) + + store.bindings = nil + require.NoError(t, requireVocabularyAudience(ctx, audience), + "without invalidation the snapshot still serves inside the bound") + + service.InvalidateHostAudiences() + require.Equal(t, codes.PermissionDenied, status.Code(requireVocabularyAudience(ctx, audience)), + "the apply path invalidates, so the next mint sees the withdrawal") +} + +// The refusal is a CONSTANT shape and carries no cardinality. +// +// An earlier version counted the members on the reasoning that counting is not +// listing. A count is information about the same secret: one refused mint told any +// caller how many solutions this deployment runs, and repeating the probe told them +// when a solution was installed or withdrawn. +func TestTheRefusalCarriesNoCardinality(t *testing.T) { + ctx := context.Background() + for _, bindings := range [][]string{ + {"acme.test.one"}, + {"acme.test.one", "acme.test.two", "acme.test.three", "acme.test.four"}, + } { + withHostAudiences(t, bindings...) + err := requireVocabularyAudience(ctx, "nothing-serves-this") + require.Error(t, err) + require.NotRegexp(t, `[0-9]`, err.Error(), + "a digit in the refusal is a cardinality channel: %s", err.Error()) + require.Contains(t, err.Error(), business.SolutionAudiencePrefix+"", + "it names the SHAPE instead") + } +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_ceiling_test.go b/module/services/accounts/code/pkg/adapters/work_context_ceiling_test.go index 2c2a5772c..cf40e42a9 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_ceiling_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_ceiling_test.go @@ -1,6 +1,7 @@ package adapters import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -16,52 +17,106 @@ func scope(kind string, actions ...string) *basev0.WorkScopeV1 { return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions} } +// The ceiling is read through the host's CURRENT vocabulary (issue #952), so these +// audiences are the real shapes — `solution:` — rather than the free +// text `allowed_audiences` used to admit. func TestEnforceActorAudience(t *testing.T) { + const ( + one = "solution:acme.test.one" + two = "solution:acme.test.two" + ) agent := func(auds ...string) *business.Principal { return &business.Principal{AgentIdentifier: "pub/agent:1.0.0", AllowedAudiences: auds} } + ctx := context.Background() t.Run("nil actor is unrestricted", func(t *testing.T) { - require.NoError(t, enforceActorAudience(nil, "anything")) + withHostAudiences(t, "acme.test.one") + require.NoError(t, enforceActorAudience(ctx, nil, one)) }) t.Run("empty ceiling is unrestricted", func(t *testing.T) { - require.NoError(t, enforceActorAudience(agent(), "anything")) + withHostAudiences(t, "acme.test.one") + require.NoError(t, enforceActorAudience(ctx, agent(), one)) }) t.Run("audience within ceiling", func(t *testing.T) { - require.NoError(t, enforceActorAudience(agent("github", "jira"), "jira")) + withHostAudiences(t, "acme.test.one", "acme.test.two") + require.NoError(t, enforceActorAudience(ctx, agent(one, two), two)) }) t.Run("audience outside ceiling is denied", func(t *testing.T) { - err := enforceActorAudience(agent("github"), "jira") + withHostAudiences(t, "acme.test.one", "acme.test.two") + err := enforceActorAudience(ctx, agent(one), two) require.Equal(t, codes.PermissionDenied, status.Code(err)) }) + + // THE READ-TIME HALF. A ceiling entry that has left the host's vocabulary is + // DEAD, not granted: the solution it named has been withdrawn, so it describes + // no consumer. Write-time validation cannot cover this, because the set shrinks + // by DELIVERY, long after any write. + t.Run("a withdrawn solution's audience is no longer granted", func(t *testing.T) { + withHostAudiences(t, "acme.test.one") // two has been withdrawn + require.NoError(t, enforceActorAudience(ctx, agent(one, two), one), + "the live entry must still be granted, or a withdrawal would revoke the whole ceiling") + err := enforceActorAudience(ctx, agent(one, two), two) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "still serves", + "the refusal must say the audience is no longer served, not that it was never allowed") + }) + + // A ceiling that has gone ENTIRELY dead refuses, and is not read as an empty + // ceiling. An empty stored list means "unrestricted"; a list that was non-empty + // and is now wholly withdrawn means "every consumer this agent could address is + // gone". Collapsing the second into the first is the most permissive reading of + // the most restrictive state. + t.Run("a wholly withdrawn ceiling refuses rather than reading as unrestricted", func(t *testing.T) { + withHostAudiences(t) // no declared bindings at all + err := enforceActorAudience(ctx, agent(one, two), one) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "withdrawn") + }) + + // And a vocabulary that cannot be READ fails closed: a ceiling nobody can + // resolve is not an unrestricted one. + t.Run("an unreadable vocabulary fails the ceiling closed", func(t *testing.T) { + store := withHostAudiences(t, "acme.test.one") + store.err = context.DeadlineExceeded + err := enforceActorAudience(ctx, agent(one), one) + require.Equal(t, codes.Unavailable, status.Code(err)) + }) } func TestEnforceActorCeiling(t *testing.T) { + const live = "solution:acme.test.one" agent := &business.Principal{ AgentIdentifier: "pub/agent:1.0.0", - AllowedAudiences: []string{"github"}, + AllowedAudiences: []string{live}, AllowedScopes: []string{"repo", "issue"}, } + ctx := context.Background() t.Run("audience and scopes within ceiling", func(t *testing.T) { - require.NoError(t, enforceActorCeiling(agent, "github", + withHostAudiences(t, "acme.test.one") + require.NoError(t, enforceActorCeiling(ctx, agent, live, []*basev0.WorkScopeV1{scope("repo", "read"), scope("issue", "write")})) }) t.Run("scope outside ceiling is denied", func(t *testing.T) { - err := enforceActorCeiling(agent, "github", + withHostAudiences(t, "acme.test.one") + err := enforceActorCeiling(ctx, agent, live, []*basev0.WorkScopeV1{scope("repo", "read"), scope("secret", "read")}) require.Equal(t, codes.PermissionDenied, status.Code(err)) }) t.Run("audience outside ceiling is denied before scopes", func(t *testing.T) { - err := enforceActorCeiling(agent, "gitlab", []*basev0.WorkScopeV1{scope("repo", "read")}) + withHostAudiences(t, "acme.test.one", "acme.test.two") + err := enforceActorCeiling(ctx, agent, "solution:acme.test.two", []*basev0.WorkScopeV1{scope("repo", "read")}) require.Equal(t, codes.PermissionDenied, status.Code(err)) }) t.Run("empty scope ceiling admits any resource kind", func(t *testing.T) { - open := &business.Principal{AllowedAudiences: []string{"github"}} - require.NoError(t, enforceActorCeiling(open, "github", + withHostAudiences(t, "acme.test.one") + open := &business.Principal{AllowedAudiences: []string{live}} + require.NoError(t, enforceActorCeiling(ctx, open, live, []*basev0.WorkScopeV1{scope("anything", "do")})) }) t.Run("nil actor is unrestricted", func(t *testing.T) { - require.NoError(t, enforceActorCeiling(nil, "x", []*basev0.WorkScopeV1{scope("y", "z")})) + withHostAudiences(t, "acme.test.one") + require.NoError(t, enforceActorCeiling(ctx, nil, live, []*basev0.WorkScopeV1{scope("y", "z")})) }) } diff --git a/module/services/accounts/code/pkg/adapters/work_context_content_read_test.go b/module/services/accounts/code/pkg/adapters/work_context_content_read_test.go index 32b1b2ef7..2c88c3100 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_content_read_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_content_read_test.go @@ -23,6 +23,7 @@ func withComposition(t *testing.T, modules map[string][]string) { Prefix: prefix, Resources: resources, } } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModulePrincipals(registry) service = svc t.Cleanup(func() { service = previous }) diff --git a/module/services/accounts/code/pkg/adapters/work_context_cutover_test.go b/module/services/accounts/code/pkg/adapters/work_context_cutover_test.go new file mode 100644 index 000000000..3c1425647 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_cutover_test.go @@ -0,0 +1,117 @@ +package adapters + +import ( + "crypto/ed25519" + "strings" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + coreworkcontext "github.com/codefly-dev/core/workcontext" + sdkworkcontext "github.com/codefly-dev/sdk-go/workcontext" +) + +// The Work Context switch is a HARD CUTOVER, and this is the test that says so +// before someone starts it incrementally. +// +// The host mints and verifies through github.com/codefly-dev/sdk-go/workcontext. +// core/workcontext is the one implementation it must end up on — a host that +// carries its own copy of its dependency's rules owns the copy that drifts, and +// that is the whole reason the switch is owed. The question this pins is not +// WHETHER to switch but whether it can be done one call site at a time, and the +// answer is no, for two reasons that are easy to miss by reading either package +// alone: +// +// 1. THE WIRE FORMATS ARE INCOMPATIBLE AT THE ENCODING, not at a field. sdk-go +// signs a JSON payload; core signs a deterministic protobuf encoding. core +// refuses an sdk-go capability as "not a core token" before it reaches any +// field at all — so there is no dual-read window, no verifier that accepts +// both, and every capability minted before the cutover is refused after it. +// That is the behaviour the owner's "no backward compatibility" rule asks +// for, but it has to be planned as a cold cutover rather than discovered. +// +// 2. MINTING A CORE CAPABILITY IS GATED ON A FIELD THIS HOST CANNOT ANSWER. +// basev0.WorkContextV1's seal is `(buf.validate.field).required = true`, and +// WorkSealV1.image_digest is min_len 1 matching ^sha256:[a-f0-9]{64}$. So a +// core mint needs the digest of the image the authenticated workload is +// actually running. The mint authenticates with a shared secret: there is no +// TokenReview and no Kubernetes pod reader in this tree, so there is nothing +// it can consult about the pod. Filling it from the approved record instead +// makes the check compare approved against approved — it type-checks, passes +// for every caller including the superseded pod it exists to refuse, and no +// test fails. core records that shape in docs/architecture.md as "A required +// input needs an independent source". +// +// So the switch waits on the mint's workload authentication (core #692's C1 and +// the human-session half of C2, both the owner's), not on a core pin. When it +// lands, this test is DELETED rather than updated: its subject is the gap. +func TestTheWorkContextCutoverIsAtomic(t *testing.T) { + _, private, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("key: %v", err) + } + signer, err := sdkworkcontext.NewWorkContextSigner(sdkworkcontext.WorkContextSignerOptions{ + Issuer: "saas-starter", KeyID: "k1", PrivateKey: private, + }) + if err != nil { + t.Fatalf("signer: %v", err) + } + token, context, err := signer.StartTask(sdkworkcontext.StartTaskInput{ + Audience: "accounts", TenantID: "t1", OwnerPrincipalID: "p1", + TaskID: "task1", SessionID: "s1", AuthorizationRevision: 1, + ReplayPolicy: sdkworkcontext.WorkContextReplayIdempotent, + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "accounts.users", Actions: []string{"read"}}}, + TTL: time.Minute, + }) + if err != nil { + t.Fatalf("mint a capability the way this host mints today: %v", err) + } + + // The seal core requires is absent, because sdk-go has no concept of one. + // Asserted so that "sdk-go grew a seal" is a visible change rather than a + // silent reason this test stops meaning what it says. + if context.GetSeal() != nil { + t.Fatal("sdk-go now emits a seal; re-read whether the cutover is still atomic") + } + + // And core will not read it. Inspect is the WEAKEST core entrypoint — it + // verifies no signature and holds no trust root — so a refusal here means + // Verify and Authenticate refuse it too, and no partial switch can route + // one call site through core while the mint stays where it is. + _, err = coreworkcontext.Inspect(token.Encoded()) + if err == nil { + t.Fatal("core READ an sdk-go capability: the formats have converged and the switch may no longer be atomic — re-read this test's premise before trusting it") + } + if !strings.Contains(err.Error(), "not a core token") { + t.Fatalf("core refused the capability for a different reason than the format: %v\n"+ + "This test is about the ENCODING boundary; a refusal on some other ground means the premise moved.", err) + } + + // NON-VACUITY. Everything above would also pass if Inspect simply refused + // every string it was handed, which would make this test a tautology about + // core rather than a statement about the two formats. So core must read + // core: its own shipped fixtures go through the same call, and at least one + // has to be readable. + // + // Inspect is structural, so an ACCEPTED fixture and a fixture refused for a + // reason only a verifier could reach (an expired window, a superseded + // revision, a consumed single-use capability) are both readable here; what + // must not happen is every one of them failing to decode. + fixtures, err := coreworkcontext.Fixtures(time.Now()) + if err != nil { + t.Fatalf("core fixtures: %v", err) + } + if len(fixtures) == 0 { + t.Fatal("core shipped no capability fixtures, so nothing proved Inspect reads anything") + } + var read int + for _, fixture := range fixtures { + if _, err := coreworkcontext.Inspect(fixture.Token); err == nil { + read++ + } + } + if read == 0 { + t.Fatalf("Inspect read 0 of core's own %d fixtures, so its refusal above says nothing about the sdk-go format", len(fixtures)) + } + t.Logf("Inspect read %d of core's own %d fixtures and refused the sdk-go capability", read, len(fixtures)) +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index 432eed10f..81843a09c 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -375,14 +375,18 @@ func (s *WorkContextAuthorityServer) taskBoundary( if seed.Publisher != identity.Publisher { return "", status.Error(codes.PermissionDenied, "solution registration is owned by another publisher") } - // The backend half is the half that mints. One whose lease has lapsed is a - // deployment that stopped renewing, and the gateway has already stopped - // routing to it, so minting its boundary would hand out authority for a - // solution nothing can reach. + // The backend half is the half that mints, and "serving" is DELIVERED + // presence: the applied declared generation carries a backend half on a + // non-tombstoned declared row. It was a lease expiry, which nothing renews + // any more — the renewal path is deleted and a tombstone is the only + // withdrawal — so the refusal names the columns it read rather than leaving + // an operator to guess which half never arrived. if !seed.BackendServing { - return "", status.Error(codes.FailedPrecondition, "solution backend registration is not serving") + return "", status.Errorf(codes.FailedPrecondition, + "solution backend registration is not serving: the declared row for %q carries no backend half (%s is absent)", + identity.SolutionID, seed.MissingBackendHalf) } - boundary, err := business.SolutionRuntimeBoundary(seed.Seed, orgID) + boundary, err := business.SolutionRuntimeBoundary(seed.BindingID, orgID) if err != nil { return "", status.Error(codes.Internal, "cannot derive the solution runtime boundary") } @@ -440,6 +444,9 @@ func (s *WorkContextAuthorityServer) StartTask( if err := Validate(req); err != nil { return nil, err } + if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { + return nil, err + } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) if err != nil { return nil, err @@ -459,7 +466,7 @@ func (s *WorkContextAuthorityServer) StartTask( if err != nil { return nil, err } - if err := enforceActorCeiling(facts.Actor, req.GetAudience(), scopes); err != nil { + if err := enforceActorCeiling(ctx, facts.Actor, req.GetAudience(), scopes); err != nil { return nil, err } @@ -515,6 +522,9 @@ func (s *WorkContextAuthorityServer) StartInstallationTask( if err := requireInternalCredential(ctx); err != nil { return nil, err } + if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { + return nil, err + } if s == nil || s.configureErr != nil || s.signer == nil || s.authority == nil { return nil, status.Error(codes.FailedPrecondition, "Work Context authority is not configured") } @@ -543,7 +553,7 @@ func (s *WorkContextAuthorityServer) StartInstallationTask( if facts.Actor == nil { return nil, status.Error(codes.Internal, "installation authority resolved without an agent actor") } - if err := enforceActorCeiling(facts.Actor, req.GetAudience(), scopes); err != nil { + if err := enforceActorCeiling(ctx, facts.Actor, req.GetAudience(), scopes); err != nil { return nil, err } actors := []*basev0.WorkActorV1{{ @@ -581,6 +591,78 @@ func (s *WorkContextAuthorityServer) StartInstallationTask( // another service non-interchangeable even though one key signs both. const ModuleWorkContextAudience = "module-capabilities" +// requireMintableAudience is the audience rule EVERY mint runs, and the reason it +// is one function rather than a line repeated at each site. +// +// Two refusals, each naming what it refused: +// +// 1. EMPTY. An audience is what makes two capabilities signed by one key +// non-interchangeable, so a token stamped with none is good at whichever +// consumer will take it. It was legal on every caller-supplied mint, and the +// verifier could not make up the difference: the expectation check treats an +// empty EXPECTED audience as "do not check" (sdk-go +// workcontext/work_context.go:570, `check.want != ""`), so a site with no +// value to pass and a token with no value to compare agreed, vacuously. The +// host's answer is to stamp a value always, which is what makes the sentinel +// unreachable from here — see the PR body for the upstream half, which is +// sdk-go's to delete and not this repo's to patch. +// +// 2. The capability surface's OWN audience. A token addressed to +// "module-capabilities" is what that surface accepts as a MODULE'S IDENTITY +// (VerifyModuleWorkContext expects exactly this audience and then reads the +// principal out of the actor chain). A caller-supplied mint that could name +// it would hand any owner a second spelling of a module identity — sealed +// with scopes that surface never reads. StartModuleTask stamps it itself; +// nothing a caller asks for may. +// +// These two are STRUCTURAL and need no state, which is why they are separate from +// the vocabulary check below: they hold even when the registry cannot be read, so a +// registry outage never turns into a mint that stamps an empty audience. +func requireMintableAudience(audience string) error { + if strings.TrimSpace(audience) == "" { + return status.Error(codes.InvalidArgument, + "a Work Context audience is required: a capability with no audience is good at whichever consumer accepts it") + } + if audience == ModuleWorkContextAudience { + return status.Errorf(codes.PermissionDenied, + "audience %q is this host's module capability surface and is never caller-supplied: a context addressed to it is read as a module identity", + ModuleWorkContextAudience) + } + return nil +} + +// requireVocabularyAudience is the second half: the audience must be in the host's +// CLOSED, DERIVED set — the module capability audience, or +// `solution:` for a declared, non-tombstoned binding, and nothing +// else (business.RequireHostAudience). +// +// Both halves run, in this order, and neither subsumes the other. The structural +// half needs no state and therefore holds during a registry outage. The +// vocabulary half needs the declared registry and FAILS CLOSED when it cannot be +// read: a set that cannot be resolved must not read as "no audiences are valid" +// *or* as "any is", so the mint refuses and says which capability the deployment +// is missing. +// +// The set is derived from delivered presence, so it changes only by delivery. A +// caller never adds to it by asking, and a withdrawal removes an audience with its +// binding's tombstone — which is what makes "the consumer this capability names +// still exists" a property of the mint rather than of a reviewer's attention. +func requireVocabularyAudience(ctx context.Context, audience string) error { + if err := requireMintableAudience(audience); err != nil { + return err + } + if err := service.RequireHostAudience(ctx, audience); err != nil { + if errors.Is(err, business.ErrAudienceNotInHostVocabulary) { + return status.Error(codes.PermissionDenied, err.Error()) + } + // The set could not be resolved. Unavailable, not PermissionDenied: the + // caller may be entitled and nothing here says otherwise, and an operator + // reading a 503 looks at the registry rather than at a grant. + return status.Error(codes.Unavailable, err.Error()) + } + return nil +} + // ErrWorkContextAuthorityUnconfigured distinguishes a deployment that never // wired the signing key from a capability this issuer refused to sign. Both // reach the caller as a failure to mint, but only one is the operator's to fix. @@ -640,10 +722,13 @@ func (s *WorkContextAuthorityServer) StartModuleOperationTask( if s == nil || s.configureErr != nil || s.signer == nil { return workcontext.WorkContextToken{}, nil, ErrWorkContextAuthorityUnconfigured } - // A binding addressed to the capability surface itself would yield a token - // that surface accepts as the module's own identity, sealed with scopes it - // never reads; refuse rather than mint two spellings of one capability. - if authority.Audience == "" || authority.Audience == ModuleWorkContextAudience { + // The same rule every mint runs, stated once: an empty audience, and the + // capability surface's own, are both refused. These two sites were already + // fail-closed and are the precedent requireMintableAudience generalises — + // consolidated rather than reimplemented, so the rule cannot hold at five + // sites and lapse at the sixth. The error KIND stays this path's own + // (ErrWorkContextInvalid), because its callers map it. + if requireMintableAudience(authority.Audience) != nil { return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: operation context audience", workcontext.ErrWorkContextInvalid) } if authority.Revision == 0 { @@ -746,6 +831,9 @@ func (s *WorkContextAuthorityServer) StartRootSession( if err := Validate(req); err != nil { return nil, err } + if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { + return nil, err + } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) if err != nil { return nil, err @@ -756,7 +844,7 @@ func (s *WorkContextAuthorityServer) StartRootSession( if err != nil { return nil, err } - if err := enforceActorAudience(actor, req.GetAudience()); err != nil { + if err := enforceActorAudience(ctx, actor, req.GetAudience()); err != nil { return nil, err } token, signed, err := s.signer.StartSession(parentToken, workcontext.StartRootSessionInput{ @@ -778,6 +866,9 @@ func (s *WorkContextAuthorityServer) ExchangeAudience( if err := Validate(req); err != nil { return nil, err } + if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { + return nil, err + } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) if err != nil { return nil, err @@ -788,12 +879,13 @@ func (s *WorkContextAuthorityServer) ExchangeAudience( if err != nil { return nil, err } - return s.exchangeVerifiedParent(parentToken, parent, actor, req) + return s.exchangeVerifiedParent(ctx, parentToken, parent, actor, req) } // exchangeVerifiedParent runs AFTER the caller's authentication and // current-parent checks. func (s *WorkContextAuthorityServer) exchangeVerifiedParent( + ctx context.Context, parentToken workcontext.WorkContextToken, parent *basev0.WorkContextV1, actor *business.Principal, req *gen.ExchangeWorkContextAudienceRequest, ) (*gen.IssuedWorkContext, error) { @@ -808,7 +900,7 @@ func (s *WorkContextAuthorityServer) exchangeVerifiedParent( // audience AND re-declare scopes, so the scope cap must apply here too, not // only the audience. Relying on the signer's attenuation guarantee alone // would leave the ceiling's scope dimension unenforced on this path. - if err := enforceActorCeiling(actor, req.GetAudience(), scopes); err != nil { + if err := enforceActorCeiling(ctx, actor, req.GetAudience(), scopes); err != nil { return nil, err } token, signed, err := s.signer.ExchangeWorkContextAudience( @@ -833,6 +925,9 @@ func (s *WorkContextAuthorityServer) StartChildSession( if err := Validate(req); err != nil { return nil, err } + if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { + return nil, err + } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) if err != nil { return nil, err @@ -862,7 +957,7 @@ func (s *WorkContextAuthorityServer) StartChildSession( "parent Work Context authorization revision is stale", ) } - if err := enforceActorCeiling(facts.Actor, req.GetAudience(), scopes); err != nil { + if err := enforceActorCeiling(ctx, facts.Actor, req.GetAudience(), scopes); err != nil { return nil, err } token, signed, err := s.signer.StartChildSession(parentToken, workcontext.StartChildSessionInput{ @@ -925,13 +1020,21 @@ func (s *WorkContextAuthorityServer) RenewWorkContext( return nil, status.Error(codes.InvalidArgument, err.Error()) } } + // The tri-state made explicit: a renewal that names no audience INHERITS the + // parent's by copying it here, and the resolved value — never the request + // field — is what is checked and what is stamped. A parent that somehow + // carries no audience therefore refuses the renewal instead of propagating + // the emptiness one generation further. audience := req.GetAudience() if audience == "" { audience = parent.GetAudience() } + if err := requireVocabularyAudience(ctx, audience); err != nil { + return nil, err + } // A renewal may re-declare attenuated scopes, so enforce both dimensions of // the actor ceiling — audience and resource kinds — not the audience alone. - if err := enforceActorCeiling(actor, audience, scopes); err != nil { + if err := enforceActorCeiling(ctx, actor, audience, scopes); err != nil { return nil, err } // A renewal must not silently loosen replay protection: an unspecified @@ -1250,8 +1353,10 @@ func (s *WorkContextAuthorityServer) resolveAuthority( // (the RBAC check resolveAuthority runs), so a finer ceiling would duplicate // that. allowed_scopes is the coarser, non-redundant "which resource kinds may // this agent ever touch via a Work Context" cap that RBAC does not express. -func enforceActorCeiling(actor *business.Principal, audience string, scopes []*basev0.WorkScopeV1) error { - if err := enforceActorAudience(actor, audience); err != nil { +func enforceActorCeiling( + ctx context.Context, actor *business.Principal, audience string, scopes []*basev0.WorkScopeV1, +) error { + if err := enforceActorAudience(ctx, actor, audience); err != nil { return err } if actor == nil || len(actor.AllowedScopes) == 0 { @@ -1271,17 +1376,43 @@ func enforceActorCeiling(actor *business.Principal, audience string, scopes []*b return nil } -func enforceActorAudience(actor *business.Principal, audience string) error { +// enforceActorAudience holds a mint to the actor's registered ceiling, and the +// ceiling is read through the host's CURRENT vocabulary (issue #952). +// +// A stored entry that has left the closed set is DEAD, NOT GRANTED. A solution +// withdrawn after an installation named its audience leaves a ceiling entry that +// no longer describes any consumer this host serves; granting it would mint a +// capability for an audience delivery has taken away. Write-time validation +// cannot cover this on its own, because the set shrinks by DELIVERY, long after +// any write — so the narrowing happens here, on the read. +// +// A ceiling narrowed to NOTHING refuses, and that is the one case where this is +// not merely a filter: an empty stored list means "unrestricted", but a list that +// was non-empty and is now entirely dead means "every consumer this agent was +// allowed to address is gone". Collapsing the second into the first would turn a +// fully-withdrawn ceiling into no ceiling at all, which is the most permissive +// reading of the most restrictive state. +func enforceActorAudience(ctx context.Context, actor *business.Principal, audience string) error { if actor == nil || len(actor.AllowedAudiences) == 0 { return nil } - for _, allowed := range actor.AllowedAudiences { + live, err := service.LiveHostAudiences(ctx, actor.AllowedAudiences) + if err != nil { + // Fail closed: a ceiling that cannot be read is not an unrestricted one. + return status.Error(codes.Unavailable, err.Error()) + } + if len(live) == 0 { + return status.Errorf(codes.PermissionDenied, + "agent %s has %d allowed audience(s) and this host serves none of them any more: the solutions they named have been withdrawn", + actor.AgentIdentifier, len(actor.AllowedAudiences)) + } + for _, allowed := range live { if allowed == audience { return nil } } return status.Errorf(codes.PermissionDenied, - "audience %q is outside agent %s allowed audiences", audience, actor.AgentIdentifier) + "audience %q is outside agent %s allowed audiences this host still serves", audience, actor.AgentIdentifier) } func workContextScopes( diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go index 8094eea5b..34d4b68b1 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go @@ -416,6 +416,23 @@ func (renewMembershipStore) WithOrgTx(ctx context.Context, _ string, fn func(con return fn(ctx) } +// The host's audience vocabulary is derived, and every mint now resolves it +// (business.RequireHostAudience), so a Store that cannot answer this read fails +// every mint closed — which is the right production behaviour and would make these +// tests fail on the vocabulary instead of on the mint they are about. +// +// This store declares NO solution bindings. The audiences these tests mint for are +// declared MODULE prefixes, the set's third member, installed by +// declareTestAudiences below — which is what they are in a real deployment too: +// a forwarded viewer context names the module it was minted for. +func (renewMembershipStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (renewMembershipStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + return nil, nil +} + func (renewMembershipStore) GetPlatformRole(context.Context, string) (string, error) { return "", nil } @@ -488,6 +505,7 @@ func TestRenewWorkContextExtendsDelegatedAuthorityForCurrentActor(t *testing.T) svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) @@ -529,6 +547,7 @@ func TestRenewWorkContextRejectsScopeWidening(t *testing.T) { svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) @@ -572,6 +591,7 @@ func TestRenewWorkContextRejectsAudienceOutsideActorCeiling(t *testing.T) { svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) @@ -628,6 +648,7 @@ func TestRenewWorkContextRejectsScopeOutsideActorCeiling(t *testing.T) { svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) @@ -684,6 +705,7 @@ func TestRenewWorkContextPreservesSingleUseReplayPolicy(t *testing.T) { svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) @@ -753,6 +775,7 @@ func TestStartTaskRootsTaskInCallerVerifiedSession(t *testing.T) { svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) request := func(sessionID string) *gen.StartTaskWorkContextRequest { @@ -838,6 +861,7 @@ func TestStartRootSessionGeneratesFreshRootSession(t *testing.T) { svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) request := func(parent workcontext.WorkContextToken, sessionID string) *gen.StartRootSessionWorkContextRequest { diff --git a/module/services/accounts/code/pkg/adapters/work_context_seal_source.go b/module/services/accounts/code/pkg/adapters/work_context_seal_source.go new file mode 100644 index 000000000..edab46f2e --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_seal_source.go @@ -0,0 +1,167 @@ +package adapters + +import ( + "context" + "errors" + + "accounts/pkg/business" + + "github.com/codefly-dev/core/workcontext" +) + +// The host as core's SealSource, which is what makes the Work Context cutover +// possible at all. +// +// Core v0.9.0's Authenticator REQUIRES a SealSource — its own type comment says +// there is "no mode in which the seal comparison is skipped, or in which the +// authorization revision is assumed, by any entrypoint". So the host cannot +// verify a core capability without answering, live, the four questions a seal is +// compared against. This is the adapter that answers them from what the host +// already knows. +// +// EVERY METHOD IS A LIVE READ, and that is the point rather than an +// implementation detail. A seal records what was true at mint; these answer what +// is true now; and the difference is every revocation that reaches a capability +// already in flight. An implementation that cached, or that derived an answer +// from the capability being checked, would make the comparison a tautology — +// which is the defect three review rounds of this PR have caught in other +// places. + +// WorkContextSealSource answers core's seal comparisons from the host's own +// state. +type WorkContextSealSource struct { + authority business.ModuleAuthorityStore + builds business.ExecutionAuthority + bindings business.ModuleOperationBindingStore + // tenant is the organisation these reads are scoped to. + // + // Core's SealSource takes no organisation: it asks about a principal and an + // installation, because in its model those identify the authority. The host + // needs one to read `installations` and `principal_authorization_revisions`, + // both of which are per-organisation — so the scope is bound when the source + // is constructed, for the tenant the authenticator serves, rather than + // guessed per call from the capability being verified. Reading it from the + // capability would let the capability choose the scope its own seal is + // compared in. + tenant string +} + +// NewWorkContextSealSource binds a seal source to one tenant. +func NewWorkContextSealSource( + tenant string, + authority business.ModuleAuthorityStore, + builds business.ExecutionAuthority, + bindings business.ModuleOperationBindingStore, +) *WorkContextSealSource { + return &WorkContextSealSource{ + authority: authority, builds: builds, bindings: bindings, tenant: tenant, + } +} + +// Seal answers the live installation a capability names. +// +// Core compares the returned installation id and revision against the seal's. +// `ErrModuleInstallationInactive` becomes an error rather than a zero Seal: a +// zero revision is a REAL revision a fresh organisation has, so returning one +// for a revoked installation would compare equal to a capability sealed before +// any revision existed. +func (s *WorkContextSealSource) Seal( + ctx context.Context, principalID, installationID string, +) (workcontext.Seal, error) { + if s.authority == nil { + return workcontext.Seal{}, errors.New("no live authority read is wired, so no seal can be answered") + } + live, err := s.authority.LiveModuleAuthority(ctx, principalID, s.tenant, installationID) + if err != nil { + return workcontext.Seal{}, err + } + return workcontext.Seal{ + InstallationID: live.InstallationID, + InstallationRevision: live.InstallationRevision, + }, nil +} + +// PrincipalEpoch answers the principal's current envelope epoch. +// +// Read with no installation, because the epoch is a property of the principal in +// the organisation and not of any installation — passing one would make a +// principal's epoch unanswerable for a capability that names no installation, +// which is every capability until the mint seals one. +func (s *WorkContextSealSource) PrincipalEpoch( + ctx context.Context, principalID string, +) (uint64, error) { + if s.authority == nil { + return 0, errors.New("no live authority read is wired, so no principal epoch can be answered") + } + live, err := s.authority.LiveModuleAuthority(ctx, principalID, s.tenant, "") + if err != nil { + return 0, err + } + return live.ProducerEpoch, nil +} + +// ApprovedBuild answers what this host approves for a principal. +// +// Core's own documentation puts the monotonicity contract on the IMPLEMENTER: +// it compares for equality and cannot detect a source moving backwards. That +// contract is kept by `business.MonotonicApprovedBuilds`, including the clause +// core's own in-memory source missed — a digest change must come with an +// incarnation increase, or a swap-back at a fixed counter revives every +// capability the move away had revoked. +// +// The three states map onto core's: a record answers; a known principal bearing +// none is `ErrNoApprovedBuild`, which core reads as "bears no execution" and +// which is NOT a refusal; and an unknown principal is an error, because a +// capability for an identity this host has never heard of must not be admitted +// as merely unbound. +func (s *WorkContextSealSource) ApprovedBuild( + ctx context.Context, principalID string, +) (string, uint64, error) { + if s.builds == nil { + return "", 0, errors.New("no approved-build authority is wired, so no execution can be answered") + } + digest, incarnation, err := s.builds.ApprovedBuild(ctx, principalID) + switch { + case errors.Is(err, business.ErrNoApprovedBuild): + // Translated to core's sentinel, which its mint and verify branch on. + // Returning the host's own error here would read to core as a failure + // to answer rather than as "bears none", and those are different. + return "", 0, workcontext.ErrNoApprovedBuild + case err != nil: + return "", 0, err + } + return string(digest), incarnation, nil +} + +// OperationBinding resolves one binding by its id, exactly. +// +// By id and nothing else, which is core's contract and the host's own rule: the +// search this replaces walked a principal's bindings for one whose scopes +// contained the presented set, so a capability naming a narrow binding was +// satisfied by any wider binding the principal also held. +// +// The host's store takes a principal as well, because a binding is held BY a +// principal and a lookup that ignored it would resolve another principal's +// binding by id. Core asks without one, so the binding's own principal is +// returned for core to compare — the record answers who holds it rather than the +// caller asserting it. +func (s *WorkContextSealSource) OperationBinding( + ctx context.Context, bindingID string, +) (workcontext.OperationBinding, error) { + if s.bindings == nil { + return workcontext.OperationBinding{}, errors.New("no operation-binding store is wired, so no binding can be resolved") + } + binding, err := s.bindings.OperationBindingByID(ctx, "", bindingID) + if err != nil { + return workcontext.OperationBinding{}, err + } + if binding == nil { + return workcontext.OperationBinding{}, workcontext.ErrNoBinding + } + return workcontext.OperationBinding{ + ID: binding.BindingID, + Revision: binding.Revision, + }, nil +} + +var _ workcontext.SealSource = (*WorkContextSealSource)(nil) diff --git a/module/services/accounts/code/pkg/adapters/work_context_seal_source_test.go b/module/services/accounts/code/pkg/adapters/work_context_seal_source_test.go new file mode 100644 index 000000000..13cd5e7d5 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_seal_source_test.go @@ -0,0 +1,214 @@ +//go:build pure + +package adapters + +import ( + "context" + "errors" + "testing" + + "accounts/pkg/business" + + "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// The host as core's SealSource. Every method is a LIVE read, and these pin the +// translations where getting one wrong would make a comparison tautological or +// silently permissive. + +type sealAuthority struct { + live *business.LiveModuleAuthority + err error + // asked records the arguments, because the scope a seal is compared in must + // come from the source's construction and never from the capability. + askedPrincipal, askedOrg, askedInstallation string +} + +func (f *sealAuthority) LiveModuleAuthority( + _ context.Context, principalID, orgID, installationID string, +) (*business.LiveModuleAuthority, error) { + f.askedPrincipal, f.askedOrg, f.askedInstallation = principalID, orgID, installationID + if f.err != nil { + return nil, f.err + } + return f.live, nil +} + +type sealBuilds struct { + digest business.ApprovedDigest + incarnation uint64 + err error +} + +func (f *sealBuilds) ApprovedBuild( + context.Context, string, +) (business.ApprovedDigest, uint64, error) { + if f.err != nil { + return "", 0, f.err + } + return f.digest, f.incarnation, nil +} + +type sealBindings struct { + binding *business.ModuleOperationBinding + err error + asked []string +} + +func (f *sealBindings) OperationBindingByID( + _ context.Context, _, bindingID string, +) (*business.ModuleOperationBinding, error) { + f.asked = append(f.asked, bindingID) + if f.err != nil { + return nil, f.err + } + return f.binding, nil +} + +// Seal answers the live installation, scoped to the tenant the SOURCE was built +// for — not one read off the capability being verified. +// +// Core's SealSource takes no organisation, so if the scope came from the +// capability, the capability would choose the scope its own seal is compared in. +func TestSealIsScopedToTheSourcesTenantNotTheCapability(t *testing.T) { + authority := &sealAuthority{live: &business.LiveModuleAuthority{ + InstallationID: "install-1", InstallationRevision: 9, ProducerEpoch: 4, + }} + source := NewWorkContextSealSource("org-1", authority, nil, nil) + + seal, err := source.Seal(context.Background(), "principal-1", "install-1") + require.NoError(t, err) + require.Equal(t, "install-1", seal.InstallationID) + require.Equal(t, uint64(9), seal.InstallationRevision) + require.Equal(t, "org-1", authority.askedOrg, "the tenant is the source's, bound at construction") + require.Equal(t, "install-1", authority.askedInstallation) +} + +// A revoked installation is an ERROR, not a zero Seal. +// +// Zero is a real revision a fresh organisation has, so returning one would +// compare equal to a capability sealed before any revision existed — the +// absence-is-not-zero rule, in core's direction. +func TestARevokedInstallationIsAnErrorNotAZeroSeal(t *testing.T) { + source := NewWorkContextSealSource("org-1", + &sealAuthority{err: business.ErrModuleInstallationInactive}, nil, nil) + + seal, err := source.Seal(context.Background(), "principal-1", "install-gone") + require.Error(t, err) + require.Zero(t, seal.InstallationRevision) + require.ErrorIs(t, err, business.ErrModuleInstallationInactive) +} + +// PrincipalEpoch reads with NO installation, because the epoch belongs to the +// principal in the organisation. +// +// Passing one would make a principal's epoch unanswerable for a capability that +// names no installation — which is every capability until the mint seals one. +func TestPrincipalEpochReadsWithoutAnInstallation(t *testing.T) { + authority := &sealAuthority{live: &business.LiveModuleAuthority{ProducerEpoch: 7}} + source := NewWorkContextSealSource("org-1", authority, nil, nil) + + epoch, err := source.PrincipalEpoch(context.Background(), "principal-1") + require.NoError(t, err) + require.Equal(t, uint64(7), epoch) + require.Empty(t, authority.askedInstallation, + "an epoch is a property of the principal, not of any installation") +} + +// "Bears no approved build" is translated to CORE's sentinel, because core's +// mint and verify branch on it. +// +// Returning the host's own error would read to core as a failure to ANSWER +// rather than as "bears none", and those have opposite consequences: one is an +// outage, the other is a capability that legitimately carries no execution. +func TestBearingNoApprovedBuildBecomesCoresSentinel(t *testing.T) { + source := NewWorkContextSealSource("org-1", nil, + &sealBuilds{err: business.ErrNoApprovedBuild}, nil) + + _, _, err := source.ApprovedBuild(context.Background(), "principal-1") + require.ErrorIs(t, err, workcontext.ErrNoApprovedBuild) +} + +// An UNKNOWN principal is NOT translated to "bears none". +// +// Collapsing them would admit a capability for an identity this host has never +// heard of as merely unbound — the dangerous direction, and the one the +// three-state distinction exists for. +func TestAnUnknownPrincipalIsNotReportedAsBearingNone(t *testing.T) { + source := NewWorkContextSealSource("org-1", nil, + &sealBuilds{err: business.ErrUnknownExecutionPrincipal}, nil) + + _, _, err := source.ApprovedBuild(context.Background(), "stranger") + require.Error(t, err) + require.NotErrorIs(t, err, workcontext.ErrNoApprovedBuild, + "an unknown principal must not read to core as a principal bearing no execution") +} + +// An approved build answers with its digest and incarnation, both from the +// authority rather than from anything the caller presented. +func TestAnApprovedBuildAnswersDigestAndIncarnation(t *testing.T) { + source := NewWorkContextSealSource("org-1", nil, + &sealBuilds{digest: business.ApprovedDigest("sha256:abc"), incarnation: 5}, nil) + + digest, incarnation, err := source.ApprovedBuild(context.Background(), "principal-1") + require.NoError(t, err) + require.Equal(t, "sha256:abc", digest) + require.Equal(t, uint64(5), incarnation) +} + +// A binding is resolved by EXACT id, and a missing one is core's ErrNoBinding +// rather than a zero binding. +// +// A zero OperationBinding would have revision 0, which a capability sealed at +// revision 0 would match — so "no such binding" must not be expressible as a +// successful answer. +func TestAMissingBindingIsCoresSentinelNotAZeroBinding(t *testing.T) { + bindings := &sealBindings{binding: nil} + source := NewWorkContextSealSource("org-1", nil, nil, bindings) + + _, err := source.OperationBinding(context.Background(), "binding-gone") + require.ErrorIs(t, err, workcontext.ErrNoBinding) + require.Equal(t, []string{"binding-gone"}, bindings.asked, + "exactly the named binding is looked up — never a search") +} + +// A present binding answers with the record's own revision. +func TestAPresentBindingAnswersItsRevision(t *testing.T) { + source := NewWorkContextSealSource("org-1", nil, nil, + &sealBindings{binding: &business.ModuleOperationBinding{BindingID: "binding-1", Revision: 3}}) + + binding, err := source.OperationBinding(context.Background(), "binding-1") + require.NoError(t, err) + require.Equal(t, "binding-1", binding.ID) + require.Equal(t, uint64(3), binding.Revision) +} + +// An unwired source REFUSES every question rather than answering a zero. +// +// Core has no seal-less mode, so a source that answered zeros would make every +// comparison pass while nothing was read — the exact shape of a check that does +// not run. +func TestAnUnwiredSealSourceRefusesEveryQuestion(t *testing.T) { + source := NewWorkContextSealSource("org-1", nil, nil, nil) + + _, err := source.Seal(context.Background(), "p", "i") + require.Error(t, err) + _, err = source.PrincipalEpoch(context.Background(), "p") + require.Error(t, err) + _, _, err = source.ApprovedBuild(context.Background(), "p") + require.Error(t, err) + _, err = source.OperationBinding(context.Background(), "b") + require.Error(t, err) +} + +// A failing live read propagates as a failure, never as an answer. +func TestAFailingLiveReadIsNotAnAnswer(t *testing.T) { + source := NewWorkContextSealSource("org-1", + &sealAuthority{err: errors.New("connection reset")}, nil, nil) + + _, err := source.Seal(context.Background(), "p", "i") + require.Error(t, err) + _, err = source.PrincipalEpoch(context.Background(), "p") + require.Error(t, err) +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go b/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go index 22ac2a0a5..7ee9f0c82 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go @@ -10,6 +10,10 @@ import ( "testing" accountsauth "accounts/pkg/auth" + "io/fs" + "os" + "path/filepath" + "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" @@ -36,8 +40,10 @@ import ( const ( boundarySolutionA = "example-solution-a" boundarySolutionB = "example-solution-b" - boundarySeedA = "019f6c01-aaaa-7aaa-8aaa-aaaaaaaaaa01" - boundarySeedB = "019f6c01-bbbb-7bbb-8bbb-bbbbbbbbbb02" + // Binding ids, not UUIDs: the boundary is derived from the declared + // presence binding, and a binding id may carry characters a UUID may not. + boundarySeedA = "binding-solution-a-0001" + boundarySeedB = "binding-solution-b-0002" boundaryPublisher = "solution:example" // A second organization, to show one tenant's boundary is not another's. boundaryOtherOrg = "019f6bf7-5b4b-74e5-8c17-092259bb1672" @@ -79,10 +85,15 @@ func (f *solutionBoundaryAuthorityFake) SolutionRuntimeBoundarySeed( if !ok { return business.SolutionBoundarySeed{}, business.ErrSolutionRegistrationNotFound } + missing := "" + if f.backendStopped { + missing = "backend_revision and backend_upstream" + } return business.SolutionBoundarySeed{ - Seed: seed, - Publisher: f.publisher, - BackendServing: !f.backendStopped, + BindingID: seed, + MissingBackendHalf: missing, + Publisher: f.publisher, + BackendServing: !f.backendStopped, }, nil } @@ -129,16 +140,50 @@ func newSolutionBoundaryServer(t *testing.T) (*WorkContextAuthorityServer, *solu return server, authority } -// solutionBoundaryService installs the membership store requireOrgMember reads. +// solutionBoundaryService installs the membership store requireOrgMember reads, +// plus the audience vocabulary every mint resolves. func solutionBoundaryService(t *testing.T) { t.Helper() previous := service svc, err := business.NewService(renewMembershipStore{}) require.NoError(t, err) service = svc + declareTestAudiences(svc) t.Cleanup(func() { service = previous }) } +// declareTestAudiences declares the module prefixes the mint tests address, the +// way a deployment declares them: through the MODULE_PRINCIPALS registry, which is +// where the vocabulary's third member comes from. +// +// Without this every mint in these files refuses with "not an audience this host +// serves" — correctly, because an audience naming no declared consumer is exactly +// what the closed set exists to refuse. Declaring them here keeps each test about +// what it is about while still exercising the real rule: an audience NOT declared +// here is still refused, which TestTheMintAudienceVocabularyIsClosedAndDerived +// asserts directly. +// It MERGES rather than replaces: a test that declared its own grant — with the +// permission resources its content is governed by — must keep it, or the content +// surface starts answering "declares no module content" for a reason that has +// nothing to do with what the test is about. +func declareTestAudiences(svc *business.Service) { + registry := svc.ModulePrincipals() + if registry == nil { + registry = business.ModulePrincipalRegistry{} + } + for _, prefix := range []string{ + "runtime.tasks", "runtime.operations", "tool.test", "consumer.test", + "acme.collection", "rows", "documents", "example", "example-producer", + } { + id := business.ModulePrincipalID(prefix) + if _, declared := registry[id]; declared { + continue + } + registry[id] = business.ModulePrincipalGrant{Prefix: prefix} + } + svc.SetModulePrincipals(registry) +} + // boundaryCaller is the viewer behind the passthrough: the person whose bearer // the solution forwarded, owner and actor of the Task. func boundaryCaller() context.Context { @@ -345,16 +390,28 @@ func TestOrdinaryMintsAreUnchangedByTheBoundary(t *testing.T) { }) } -// The solution identity reaches a handler only from a trusted forwarder, on -// both transports. Without that, an authenticated viewer could name any -// solution and mint under its boundary — the cross-boundary access the host -// assigns the boundary to prevent. -func TestForwardedSolutionIdentityIsOnlyTrustedBehindTheGateway(t *testing.T) { +// NOTHING ON THE WIRE SETS A VERIFIED SOLUTION, and a request that claims one +// is REFUSED BY NAME. +// +// This test asserted the opposite until the cutover: that the solution identity +// is trusted from a trusted forwarder. That was correct while the gateway proved +// the claim from a solution's registration credential. Runtime +// self-registration is deleted, so the gateway stamps neither header and there +// is nothing left to prove it from — which turns "trusted from the gateway" +// into "trusted from whoever reached the gateway", i.e. any authenticated +// viewer naming any solution. +// +// Refused rather than ignored: a miswired runtime that silently received an +// ordinary capability would look like it worked, and would read nothing of the +// solution's runs. The message names the prerequisite instead. +func TestAClaimedSolutionIdentityIsRefusedByNameAndNeverTrusted(t *testing.T) { previousGateway := gatewayToken SetGatewayToken("test-gateway-token") t.Cleanup(func() { SetGatewayToken(previousGateway) }) - trustedConnect, err := (&connectPolicyInterceptor{getMinter: nil}).authorize( + // Behind a VALID gateway token — the trusted path, which is exactly where + // the old contract believed the claim. + _, err := (&connectPolicyInterceptor{getMinter: nil}).authorize( context.Background(), "/saas.accounts.v1.WorkContextService/StartTask", http.Header{ "X-Codefly-Gateway-Token": []string{"test-gateway-token"}, "X-Credential-Kind": []string{credentialKindSession}, @@ -364,9 +421,11 @@ func TestForwardedSolutionIdentityIsOnlyTrustedBehindTheGateway(t *testing.T) { "X-Codefly-Solution-Id": []string{boundarySolutionA}, "X-Codefly-Solution-Publisher": []string{boundaryPublisher}, }) - require.NoError(t, err) + require.Error(t, err, "a claimed solution identity behind the gateway token must be refused") + require.Contains(t, err.Error(), "solution attestation is not delivered on this host", + "the refusal must name the missing attestation, not a generic forwarded-identity error") - trustedGRPC, err := (&grpcPolicyAuthorizer{getMinter: nil, exposure: rpcExposureTenant}).authorize( + _, err = (&grpcPolicyAuthorizer{getMinter: nil, exposure: rpcExposureTenant}).authorize( metadata.NewIncomingContext(context.Background(), metadata.Pairs( "x-codefly-gateway-token", "test-gateway-token", "x-credential-kind", credentialKindSession, @@ -376,18 +435,30 @@ func TestForwardedSolutionIdentityIsOnlyTrustedBehindTheGateway(t *testing.T) { "x-codefly-solution-id", boundarySolutionA, "x-codefly-solution-publisher", boundaryPublisher, )), "/saas.accounts.v1.WorkContextService/StartTask") - require.NoError(t, err) + require.Error(t, err, "same claim over gRPC metadata") + require.Contains(t, err.Error(), "solution attestation is not delivered on this host") - for name, ctx := range map[string]context.Context{ - "gateway to Connect": trustedConnect, - "gateway to gRPC": trustedGRPC, + // Either header ALONE is a claim. Refusing only the complete pair would + // make the refusal depend on how completely a caller lied. + for name, headers := range map[string]http.Header{ + "id alone": {"X-Codefly-Solution-Id": []string{boundarySolutionA}}, + "publisher alone": {"X-Codefly-Solution-Publisher": []string{boundaryPublisher}}, } { - identity, ok := accountsauth.VerifiedSolution(ctx) - require.True(t, ok, name) - require.Equal(t, boundarySolutionA, identity.SolutionID, name) - require.Equal(t, boundaryPublisher, identity.Publisher, name) + t.Run(name, func(t *testing.T) { + headers.Set("X-Codefly-Gateway-Token", "test-gateway-token") + headers.Set("X-Credential-Kind", credentialKindSession) + headers.Set("X-Scopes", "") + headers.Set("X-User-Id", renewActorID) + _, err := (&connectPolicyInterceptor{getMinter: nil}).authorize( + context.Background(), "/saas.accounts.v1.WorkContextService/StartTask", headers) + require.Error(t, err) + require.Contains(t, err.Error(), "solution attestation is not delivered on this host") + }) } + // WITHOUT a gateway token the headers are deleted rather than refused: an + // anonymous prober gets the ordinary answer for its credential, not a + // signal that these headers mean something. Either way no solution is set. minter := func() accountsauth.JWTMinter { return &fixedAccessMinter{identity: &accountsauth.Identity{ UserID: uuid.MustParse(renewActorID), @@ -413,31 +484,30 @@ func TestForwardedSolutionIdentityIsOnlyTrustedBehindTheGateway(t *testing.T) { require.NoError(t, err) _, ok = accountsauth.VerifiedSolution(forgedGRPC) require.False(t, ok) +} - // A trusted forwarder's value that is not a catalog identity is refused - // rather than dropped: minting with it would seal a boundary nobody owns. - // So is an id with no publisher — the gateway stamps both from the same - // claims, so an assertion carrying one lost half of itself, and admitting it - // would skip the ownership check entirely. - for name, headers := range map[string]http.Header{ - "not a catalog identity": { - "X-Codefly-Solution-Id": []string{"Not A Solution"}, - "X-Codefly-Solution-Publisher": []string{boundaryPublisher}, - }, - "no publisher": { - "X-Codefly-Solution-Id": []string{boundarySolutionA}, - }, - } { - t.Run(name, func(t *testing.T) { - headers.Set("X-Codefly-Gateway-Token", "test-gateway-token") - headers.Set("X-Credential-Kind", credentialKindSession) - headers.Set("X-Scopes", "") - headers.Set("X-User-Id", renewActorID) - _, err := (&connectPolicyInterceptor{getMinter: nil}).authorize( - context.Background(), "/saas.accounts.v1.WorkContextService/StartTask", headers) - require.Error(t, err) - }) - } +// The source-level half of the same promise: no non-test code may put a +// verified solution into a context. A refusal in the interceptors is only as +// good as the absence of another writer, and an interceptor is easy to add. +func TestNoProductionCodeSetsAVerifiedSolution(t *testing.T) { + root := ".." + var writers []string + require.NoError(t, filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(body), "WithVerifiedSolution(") && + !strings.HasSuffix(path, "solution_identity.go") { + writers = append(writers, path) + } + return nil + })) + require.Empty(t, writers, + "WithVerifiedSolution is called outside its own declaration: a solution identity can only come from a delivered attestation, which does not exist on this host yet") } // The hole this closes (issue #1015, BLOCKER of the PR #1017 review). A @@ -456,10 +526,16 @@ func TestOrdinaryMintRefusesARegisteredSolutionsBoundary(t *testing.T) { solutionBoundaryService(t) server, authority := newSolutionBoundaryServer(t) + // The BINDING ID behind a boundary is no longer in this set, and that is a + // consequence of the re-key rather than a gap. A task_id is a UUID; a + // binding id is not, and is refused on shape before this check is reached — + // so asserting the boundary message here would assert the wrong refusal. + // TestNamingABindingIDAsATaskIDIsRefused keeps that case covered on its own + // terms, and the derived boundaries below are the values a caller could + // actually guess at. for name, taskID := range map[string]string{ "another solution's derived boundary": derivedBoundary(t, boundarySeedA, renewOrgID), "its own derived boundary": derivedBoundary(t, boundarySeedB, renewOrgID), - "the seed behind one": boundarySeedA, } { t.Run(name, func(t *testing.T) { request := boundaryMintRequest("runtime.tasks") @@ -601,3 +677,69 @@ func TestRegisteredBoundaryRefusalCoversEverySpellingThatReachesIt(t *testing.T) }) } } + +// TestNamingABindingIDAsATaskIDIsRefused keeps the case the boundary table +// above gave up when the seed stopped being a UUID. +// +// A declared presence binding id is not a task_id and never was; what changed +// is that it is now the boundary's INPUT, so a caller who learned one might try +// naming it. It is refused — and the assertion is only that it is refused and +// that the message names no solution, because the reason is a shape refusal +// rather than the boundary collision, and pinning it to the collision's wording +// would be asserting a path this value cannot reach. +func TestNamingABindingIDAsATaskIDIsRefused(t *testing.T) { + solutionBoundaryService(t) + server, _ := newSolutionBoundaryServer(t) + + request := boundaryMintRequest("runtime.tasks") + request.TaskId = boundarySeedA + + _, err := server.StartTask(boundaryCaller(), request) + require.Error(t, err) + require.NotContains(t, status.Convert(err).Message(), boundarySolutionA) + require.NotContains(t, status.Convert(err).Message(), boundarySolutionB) +} + +// TestTheSealedClaimIsTheBindingIDsDerivationAndTheRefusalNamesTheMissingHalf +// holds the two halves of the serving ruling, on the FAKE-backed path so it +// runs in CI rather than skipping on a missing DSN — which is how the mint's +// total break reached a pushed head in the first place. +// +// Half one: the boundary a mint seals is the derivation of the declared +// presence BINDING ID, not of a per-registration random. Asserted by value +// against business.SolutionRuntimeBoundary rather than by re-deriving inside +// the test, so a change to the derivation moves both sides and this still +// compares the sealed claim to the binding id's answer. +// +// Half two: a declared row with no backend half refuses "not serving" AND +// names the columns it read. A refusal that only says "not serving" sends an +// operator to look for a lease that no longer exists. +func TestTheSealedClaimIsTheBindingIDsDerivationAndTheRefusalNamesTheMissingHalf(t *testing.T) { + solutionBoundaryService(t) + server, authority := newSolutionBoundaryServer(t) + + ctx, err := accountsauth.WithVerifiedSolution(boundaryCaller(), boundarySolutionA, boundaryPublisher) + require.NoError(t, err) + + issued, err := server.StartTask(ctx, boundaryMintRequest("runtime.tasks")) + require.NoError(t, err) + want, err := business.SolutionRuntimeBoundary(boundarySeedA, renewOrgID) + require.NoError(t, err) + // Read off the SIGNED capability, not the response field: a consumer + // verifies the first. + require.Equal(t, want, boundaryOf(t, server, "runtime.tasks", issued), + "the sealed claim must be the BINDING ID's derivation") + require.NotEqual(t, boundarySeedA, issued.GetTaskId(), + "the binding id itself is the input, never the sealed value") + + // The backend half stops being delivered: no lease lapses, the declaration + // simply no longer carries it. + authority.backendStopped = true + _, err = server.StartTask(ctx, boundaryMintRequest("runtime.tasks")) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + message := status.Convert(err).Message() + require.Contains(t, message, "not serving") + require.Contains(t, message, "backend_revision") + require.Contains(t, message, "backend_upstream") + require.NotContains(t, message, "lease", "a lapsed lease is not why a declaration is absent") +} diff --git a/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go b/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go index 441785601..ad2082933 100644 --- a/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go +++ b/module/services/accounts/code/pkg/auth/ed25519/registration_issuer_test.go @@ -2,8 +2,6 @@ package ed25519minter_test import ( "context" - "os" - "path/filepath" "testing" ed25519minter "accounts/pkg/auth/ed25519" @@ -54,14 +52,18 @@ func TestRegistrationCredentialsKeepTheLiteralIssuer(t *testing.T) { `"iss":"`+publicIssuer+`"`) } -// The verifier on the other side of that contract. The frontend is TypeScript -// and this is Go, so nothing but a check like this keeps the two literals equal -// — and their disagreement is precisely what broke. -func TestTheFrontendRegistrationVerifierExpectsTheSameIssuer(t *testing.T) { - path := filepath.Join("..", "..", "..", "..", "..", "frontend", "code", "src", - "solutions", "registration-authority.ts") - source, err := os.ReadFile(path) - require.NoError(t, err, "the frontend registration verifier must exist at %s", path) - require.Contains(t, string(source), `const ISSUER = "saas-starter"`, - "the frontend verifier's issuer must match what MintSolutionRegistration signs") -} +// The verifier on the other side of that contract is GONE, and so is this +// check. +// +// It read `frontend/code/src/solutions/registration-authority.ts` and required +// its `ISSUER` literal to equal what MintSolutionRegistration signs — because +// the frontend is TypeScript and this is Go, so nothing but a check like that +// kept the two equal. This branch deletes that verifier: the frontend +// registration path is part of the writer surface runtime self-registration +// owns, and the contract now has one side. +// +// It is NOT replaced by a weaker version. A check that the Go literal equals +// itself would pass forever and assert nothing. If a declared-presence +// equivalent of that cross-language contract appears, it needs its own check +// against whatever the new consumer reads — and the shape to copy is the one +// deleted here: read the OTHER language's source and compare the literal. diff --git a/module/services/accounts/code/pkg/business/api_keys.go b/module/services/accounts/code/pkg/business/api_keys.go index fba81d6e2..8e397e83f 100644 --- a/module/services/accounts/code/pkg/business/api_keys.go +++ b/module/services/accounts/code/pkg/business/api_keys.go @@ -235,16 +235,27 @@ func (s *Service) ListAPIKeys(ctx context.Context, req *gen.ListAPIKeysRequest) // Phase 3 idea: thread orgID into the proto so the WithControlPlane step // goes away and org-admins can revoke their own keys without // platform-admin perms. +// It is a NARROWING — a credential that authenticates stops authenticating — +// so it runs under the policy log: the entry is appended to the external record +// and receipted BEFORE the key is marked, and the revocation commits in the +// same transaction as the receipt's commit. A host that cannot witness the +// append refuses rather than revoking unwitnessed: a key revocation a restore +// could silently undo is a live credential nobody knows is live. func (s *Service) RevokeAPIKey(ctx context.Context, actorID string, req *gen.RevokeAPIKeyRequest) error { // Org-scoped revoke: the store statement pins id AND organization_id, so an // org admin can never revoke another org's key by id (handler authorized - // the actor for req.OrganizationId; the WHERE enforces the binding). - if err := s.store.WithOrgTx(ctx, req.OrganizationId, func(ctx context.Context) error { - if err := s.store.RevokeAPIKey(ctx, req.Id, req.OrganizationId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventAPIKeyRevoked, "api_key", req.Id, req.OrganizationId) - }); err != nil { + // the actor for req.OrganizationId; the WHERE enforces the binding). That + // WHERE is also what keeps the revoke confined now the transaction is the + // policy log's control-plane one — the receipt relation is control-plane + // only, and the receipt and the revocation have to be the same transaction. + if err := s.WithPolicyLoggedNarrowing(ctx, + revokeAPIKeyPolicyLogEntry(actorID, req.OrganizationId, req.Id), + func(ctx context.Context) error { + if err := s.store.RevokeAPIKey(ctx, req.Id, req.OrganizationId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventAPIKeyRevoked, "api_key", req.Id, req.OrganizationId) + }); err != nil { return err } return nil diff --git a/module/services/accounts/code/pkg/business/audit_parity_test.go b/module/services/accounts/code/pkg/business/audit_parity_test.go index 513b3b6aa..64a4de98a 100644 --- a/module/services/accounts/code/pkg/business/audit_parity_test.go +++ b/module/services/accounts/code/pkg/business/audit_parity_test.go @@ -14,6 +14,11 @@ import ( // nothing can reach. var eventsEmittedOutsideRPC = []EventType{ EventActivationAchieved, + // The declared-presence reconcile pass emits this: delivery declares a + // generation and the host applies it, with no request behind it (#952). + EventSolutionHostBindingApplied, + // The reconciler tombstones declared presence outside an RPC. + EventSolutionRegistrationDeleted, // saas.approval.approved, .denied and .decision_recorded are declared by // ApprovalReviewService.DecideApprovalReview. EventApprovalAsked, diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 5fa0d8293..a5eb27f76 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -320,35 +320,38 @@ const ( // A composed module notified a tenant's administrators (NotifyOrgAdmins). EventModuleOrgAdminsNotified EventType = "saas.module.org_admins_notified" EventSolutionRegistrationMint EventType = "saas.solution.registration_minted" - EventSolutionRegistrationUpdated EventType = "saas.solution.registration_updated" EventSolutionRegistrationDeleted EventType = "saas.solution.registration_deleted" - EventAPIKeyRevoked EventType = "saas.api_key.revoked" - EventRoleCreated EventType = "saas.role.created" - EventRoleUpdated EventType = "saas.role.updated" - EventRoleDeleted EventType = "saas.role.deleted" - EventRoleAssigned EventType = "saas.role.assigned" - EventRoleRevoked EventType = "saas.role.revoked" - EventSessionRevoked EventType = "saas.session.revoked" - EventInvitationCreated EventType = "saas.invitation.created" - EventInvitationAccepted EventType = "saas.invitation.accepted" - EventInvitationRevoked EventType = "saas.invitation.revoked" - EventInvitationResent EventType = "saas.invitation.resent" - EventInvitationLinkIssued EventType = "saas.invitation.link_issued" - EventDelegationRequested EventType = "saas.delegation.requested" - EventDelegationApproved EventType = "saas.delegation.approved" - EventDelegationDenied EventType = "saas.delegation.denied" - EventDelegationAutoApproved EventType = "saas.delegation.auto_approved" - EventApprovalAsked EventType = "saas.approval.asked" - EventApprovalApproved EventType = "saas.approval.approved" - EventApprovalDenied EventType = "saas.approval.denied" - EventApprovalTimeout EventType = "saas.approval.timeout" - EventApprovalEscalated EventType = "saas.approval.escalated" - EventApprovalCancelled EventType = "saas.approval.cancelled" - EventApprovalDecisionRecorded EventType = "saas.approval.decision_recorded" - EventPrincipalCreated EventType = "saas.principal.created" - EventPrincipalRevoked EventType = "saas.principal.revoked" - EventPrincipalDisabled EventType = "saas.principal.disabled" - EventPrincipalEnabled EventType = "saas.principal.enabled" + // EventSolutionHostBindingApplied records that this host reconciled a + // declared generation (issue #952). It is emitted by the reconcile pass, not + // by a request, so it is declared outside the RPC surface. + EventSolutionHostBindingApplied EventType = "saas.solution.host_binding_applied" + EventAPIKeyRevoked EventType = "saas.api_key.revoked" + EventRoleCreated EventType = "saas.role.created" + EventRoleUpdated EventType = "saas.role.updated" + EventRoleDeleted EventType = "saas.role.deleted" + EventRoleAssigned EventType = "saas.role.assigned" + EventRoleRevoked EventType = "saas.role.revoked" + EventSessionRevoked EventType = "saas.session.revoked" + EventInvitationCreated EventType = "saas.invitation.created" + EventInvitationAccepted EventType = "saas.invitation.accepted" + EventInvitationRevoked EventType = "saas.invitation.revoked" + EventInvitationResent EventType = "saas.invitation.resent" + EventInvitationLinkIssued EventType = "saas.invitation.link_issued" + EventDelegationRequested EventType = "saas.delegation.requested" + EventDelegationApproved EventType = "saas.delegation.approved" + EventDelegationDenied EventType = "saas.delegation.denied" + EventDelegationAutoApproved EventType = "saas.delegation.auto_approved" + EventApprovalAsked EventType = "saas.approval.asked" + EventApprovalApproved EventType = "saas.approval.approved" + EventApprovalDenied EventType = "saas.approval.denied" + EventApprovalTimeout EventType = "saas.approval.timeout" + EventApprovalEscalated EventType = "saas.approval.escalated" + EventApprovalCancelled EventType = "saas.approval.cancelled" + EventApprovalDecisionRecorded EventType = "saas.approval.decision_recorded" + EventPrincipalCreated EventType = "saas.principal.created" + EventPrincipalRevoked EventType = "saas.principal.revoked" + EventPrincipalDisabled EventType = "saas.principal.disabled" + EventPrincipalEnabled EventType = "saas.principal.enabled" EventScopeNodeRegistered EventType = "saas.scope.node_registered" EventScopeGranted EventType = "saas.scope.granted" @@ -576,8 +579,11 @@ var auditEventCatalog = []AuditEventDefinition{ PayloadField{Name: "refusal_code", Kind: FieldEnum, Enum: []string{"InvalidArgument", "Unauthenticated", "PermissionDenied", "FailedPrecondition", "Unavailable", "Internal"}}), 2), mutation(EventExecutableArtifactDecision, CategoryAccess, "An administrator approved or revoked an exact executable artifact identity.", str("action"), uid("installation_id"), str("policy_id"), str("subject_digest"), str("module_principal_id")), mutation(EventSolutionRegistrationMint, CategoryAccess, "A solution was issued a gateway and frontend registration credential.", str("solution_id")), - mutation(EventSolutionRegistrationUpdated, CategoryAccess, "A solution registered or replaced one half of its runtime registration.", str("solution_id"), str("publisher"), str("half"), PayloadField{Name: "revision", Kind: FieldInt}, strs("audit_namespaces_taken_over")), mutation(EventSolutionRegistrationDeleted, CategoryAccess, "A solution registration was removed and tombstoned.", str("solution_id"), str("publisher"), PayloadField{Name: "revision", Kind: FieldInt}), + mutation(EventSolutionHostBindingApplied, CategoryAccess, + "The host reconciled a declared SolutionHostBinding generation.", + str("binding_id"), str("solution_id"), PayloadField{Name: "generation", Kind: FieldInt}, + str("digest"), str("release"), boolean("removed"), strs("routes")), mutation(EventAPIKeyRevoked, CategoryAccess, "An API key was revoked.", uid("key_id")), mutation(EventRoleCreated, CategoryAccess, "A role was created.", str("name")), mutation(EventRoleUpdated, CategoryAccess, "A role's description and permission set were replaced.", str("name"), strs("permissions")), @@ -609,9 +615,9 @@ var auditEventCatalog = []AuditEventDefinition{ mutation(EventScopeGranted, CategoryAccess, "A role was granted at a scope node.", uid("role_id"), uid("subject_id"), str("scope_path")), mutation(EventScopeRevoked, CategoryAccess, "A scope grant was revoked.", uid("role_id"), str("scope_path")), mutation(EventInstallationCreated, CategoryAccess, "A solution was installed: an agent principal, solution scope node, standing grant, and installation row were composed.", - uid("agent_principal_id"), str("solution_identifier"), uid("role_id"), uid("owner_principal_id")), - mutation(EventInstallationRevoked, CategoryAccess, "A solution was uninstalled: its agent principal and standing grant were revoked and its scope node soft-deleted.", - str("solution_identifier")), + uid("agent_principal_id"), uid("target_id"), uid("role_id"), uid("owner_principal_id"), str("allowed_audiences"), str("allowed_scopes")), + mutation(EventInstallationRevoked, CategoryAccess, "A solution was uninstalled, or the presence it was installed from was withdrawn: its agent principal and standing grant were revoked and its scope node soft-deleted.", + uid("target_id"), str("binding_id"), str("generation"), str("revoked_reason")), mutation(EventInstallationOwnershipTransferred, CategoryAccess, "An installation's owner of record was reassigned.", uid("owner_principal_id")), mutation(EventRecordShared, CategoryAccess, "A record was shared with a principal or team.", uid("role_id"), uid("subject_id")), diff --git a/module/services/accounts/code/pkg/business/audit_webhook_visibility_test.go b/module/services/accounts/code/pkg/business/audit_webhook_visibility_test.go index 6dfaa34b4..fa491dd2d 100644 --- a/module/services/accounts/code/pkg/business/audit_webhook_visibility_test.go +++ b/module/services/accounts/code/pkg/business/audit_webhook_visibility_test.go @@ -264,7 +264,7 @@ func TestParseDeclaredAuditEventTypes_ReadsVisibility(t *testing.T) { // be corrected — at boot, naming both the namespace and what it is not among — // rather than at the registration that trips over it. func TestParseModulePrincipalRegistry_ExternalNamespaceMustBeBound(t *testing.T) { - const tenant = `"tenant":"11111111-1111-1111-1111-111111111111"` + const tenant = `"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"11111111-1111-1111-1111-111111111111"` if _, err := ParseModulePrincipalRegistry( `{"example":{"namespaces":["example"],"external_namespaces":["example"],` + tenant + `}}`, ); err != nil { @@ -291,6 +291,8 @@ func externallyBound(t *testing.T, store *declaredAuditStore, solution string, n if err != nil { t.Fatal(err) } + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(ModulePrincipalRegistry{ ModulePrincipalID(solution): {Prefix: solution, Namespaces: namespaces, ExternalNamespaces: external}, }) @@ -308,7 +310,7 @@ const tenantDeclaredEvent = `{"name":"created","type":"acme.item.created","visib func TestAdmission_ExternalVisibilityNeedsTheOperatorsGrant(t *testing.T) { store := newDeclaredAuditStore() svc := externallyBound(t, store, "acme", []string{"acme"}, nil) - _, err := registerDeclaring(t, svc, "acme", nil, externalDeclaredEvent) + _, err := admitDeclaring(t, svc, "acme", externalDeclaredEvent) if !errors.Is(err, ErrSolutionAuditNamespaceNotExternal) || !errors.Is(err, ErrSolutionAuditDeclarationRejected) { t.Fatalf("err = %v, want the ungranted external namespace refused", err) } @@ -324,7 +326,7 @@ func TestAdmission_ExternalVisibilityNeedsTheOperatorsGrant(t *testing.T) { // The same namespace, without the external declaration, needs no grant. store = newDeclaredAuditStore() svc = externallyBound(t, store, "acme", []string{"acme"}, nil) - if _, err := registerDeclaring(t, svc, "acme", nil, tenantDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", tenantDeclaredEvent); err != nil { t.Fatalf("a tenant-visible declaration needs no external grant: %v", err) } if got := store.rows["acme.item.created"].declared.Visibility; got != AuditVisibilityTenant { @@ -335,7 +337,7 @@ func TestAdmission_ExternalVisibilityNeedsTheOperatorsGrant(t *testing.T) { // the visibility the delivery gates read. store = newDeclaredAuditStore() svc = externallyBound(t, store, "acme", []string{"acme"}, []string{"acme"}) - if _, err := registerDeclaring(t, svc, "acme", nil, externalDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", externalDeclaredEvent); err != nil { t.Fatalf("granted external declaration: %v", err) } admitted := store.rows["acme.item.created"].declared @@ -355,12 +357,11 @@ func TestAdmission_VisibilityIsImmutable(t *testing.T) { } { store := newDeclaredAuditStore() svc := externallyBound(t, store, "acme", []string{"acme"}, []string{"acme"}) - record, err := registerDeclaring(t, svc, "acme", nil, tc.first) + _, err := admitDeclaring(t, svc, "acme", tc.first) if err != nil { t.Fatalf("%s: first declaration: %v", name, err) } - revision := record.Revision - _, err = registerDeclaring(t, svc, "acme", &revision, tc.second) + _, err = admitDeclaring(t, svc, "acme", tc.second) if !errors.Is(err, ErrSolutionAuditDeclarationRejected) { t.Fatalf("%s: err = %v, want the visibility change refused", name, err) } @@ -377,13 +378,12 @@ func TestAdmission_VisibilityIsImmutable(t *testing.T) { // Re-declaring the same visibility is still idempotent. store := newDeclaredAuditStore() svc := externallyBound(t, store, "acme", []string{"acme"}, []string{"acme"}) - record, err := registerDeclaring(t, svc, "acme", nil, externalDeclaredEvent) + _, err := admitDeclaring(t, svc, "acme", externalDeclaredEvent) if err != nil { t.Fatalf("first: %v", err) } puts := store.puts - revision := record.Revision - if _, err := registerDeclaring(t, svc, "acme", &revision, externalDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", externalDeclaredEvent); err != nil { t.Fatalf("re-declaration: %v", err) } if store.puts != puts { diff --git a/module/services/accounts/code/pkg/business/current_installation.go b/module/services/accounts/code/pkg/business/current_installation.go index 0fc50fb0d..9b75553f5 100644 --- a/module/services/accounts/code/pkg/business/current_installation.go +++ b/module/services/accounts/code/pkg/business/current_installation.go @@ -3,7 +3,6 @@ package business import ( "context" "strings" - "unicode/utf8" gen "accounts/pkg/gen/saas/accounts/v1" @@ -47,8 +46,37 @@ func (s *Service) ModuleCurrentInstallation(ctx context.Context, caller ModuleCa if err != nil { return nil, err } - if found == nil || found.Id != id || found.OrgId != tenant || found.Status != gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE || found.RevokedAt != nil || strings.TrimSpace(found.SolutionIdentifier) == "" || !utf8.ValidString(found.SolutionIdentifier) { + if found == nil || found.Id != id || found.OrgId != tenant || found.Status != gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE || found.RevokedAt != nil || strings.TrimSpace(found.TargetId) == "" { return nil, status.Error(codes.NotFound, "active installation unavailable") } - return &gen.ModuleCurrentInstallationResponse{InstallationId: found.Id, TenantId: found.OrgId, SolutionIdentifier: found.SolutionIdentifier}, nil + // The identity, not the alias. This returned `found.SolutionIdentifier` — a + // free-text route alias a later binding may take — so a caller comparing it + // could not tell one binding's presence from its replacement's. The target is + // the consented identity and the binding is what an authority decision keys + // on; a caller that wants the alias for display asks the catalogue read, + // which serves accepted applied state and joins on this target id. + // + // A closed target is refused rather than reported: an installation whose + // presence was withdrawn is not a current installation. + // + // The read is control-plane, for the reason requireConsentedBinding states: + // `solution_targets` is global with exact grants and the request-path role + // holds no SELECT on it. + var target *SolutionTarget + if err = s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var e error + target, e = s.store.GetSolutionTarget(ctx, found.TargetId) + return e + }); err != nil { + return nil, err + } + if !target.Live() || target.BindingID == "" { + return nil, status.Error(codes.NotFound, "active installation unavailable") + } + return &gen.ModuleCurrentInstallationResponse{ + InstallationId: found.Id, + TenantId: found.OrgId, + TargetId: found.TargetId, + BindingId: target.BindingID, + }, nil } diff --git a/module/services/accounts/code/pkg/business/datasource_files.go b/module/services/accounts/code/pkg/business/datasource_files.go index 85835c22d..93f5533e7 100644 --- a/module/services/accounts/code/pkg/business/datasource_files.go +++ b/module/services/accounts/code/pkg/business/datasource_files.go @@ -81,7 +81,7 @@ type DatasourceFile struct { // otherwise the whole call is refused before visit is first called. func (s *Service) ModuleFetchDatasourceFiles(ctx context.Context, caller ModuleCaller, sourceID, version string, refs []DatasourceFileRequest, visit func(DatasourceFile, io.Reader) error) error { w := wool.Get(ctx).In("ModuleFetchDatasourceFiles") - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return err } diff --git a/module/services/accounts/code/pkg/business/datasource_files_test.go b/module/services/accounts/code/pkg/business/datasource_files_test.go index 8cf969898..49bc0cf43 100644 --- a/module/services/accounts/code/pkg/business/datasource_files_test.go +++ b/module/services/accounts/code/pkg/business/datasource_files_test.go @@ -167,6 +167,7 @@ func filesService(t *testing.T, gh *fakeGitHub, queues []string) (*business.Serv func filesServiceWithAudit(t *testing.T, gh *fakeGitHub, queues []string) (*business.Service, *business.DatasourceSource, *recordingAudit) { t.Helper() svc, audit := newDatasourceService(newDatasourceFakeStore(), &recordingProducer{}, gh) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(&fakeJobBackend{}, &fakeJobBackend{}, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: queues, CrossTenant: true}, }) diff --git a/module/services/accounts/code/pkg/business/events_authority_test.go b/module/services/accounts/code/pkg/business/events_authority_test.go index 299599ad6..2fd5f3b88 100644 --- a/module/services/accounts/code/pkg/business/events_authority_test.go +++ b/module/services/accounts/code/pkg/business/events_authority_test.go @@ -69,6 +69,7 @@ func newEventService(t *testing.T, store business.Store, transport events.Transp if err != nil { t.Fatalf("NewService: %v", err) } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(&fakeJobBackend{}, &fakeJobBackend{}, business.ModulePrincipalRegistry{ modulePrincSvc: { Queues: []string{"reference.ingest", "events.demo.a", "events.demo.b"}, diff --git a/module/services/accounts/code/pkg/business/executable_artifacts.go b/module/services/accounts/code/pkg/business/executable_artifacts.go index ddb0a91ad..d43b07518 100644 --- a/module/services/accounts/code/pkg/business/executable_artifacts.go +++ b/module/services/accounts/code/pkg/business/executable_artifacts.go @@ -21,8 +21,13 @@ import ( ) // ExecutableArtifactPolicy is an operator-installed ceiling, not supplied by -// an author. Source identifiers are mapped explicitly to installation identities; -// event namespaces are never treated as executable-source ownership. +// an author. Event namespaces are never treated as executable-source ownership. +// +// Sources maps an artifact source identifier to the delivered presence BINDING +// ID that may carry it — not to a route alias and not to an installation id. An +// alias is reusable by a later binding, so keying on one would let a +// replacement binding inherit the approval its predecessor was granted; a +// binding id names the thing the operator actually consented to. type ExecutableArtifactPolicy struct { Schema string `json:"schema"` Sources map[string]string `json:"sources"` @@ -242,9 +247,27 @@ func (s *Service) DecideExecutableArtifact(ctx context.Context, caller ModuleCal if e != nil { return e } - if installation == nil || installation.Status != gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE || health != gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY || installation.SolutionIdentifier != policy.Sources[req.Identity.Source] { + if installation == nil || installation.Status != gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE || health != gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY { return status.Error(codes.PermissionDenied, "artifact installation unavailable") } + // The authority to approve or run an artifact is keyed on the delivered + // presence BINDING, never on the route alias. + // + // This guard read `installation.SolutionIdentifier != policy.Sources[...]`, + // against the free-text alias. An alias is deliberately reusable: withdraw + // a binding and a later one may take the same route. So an approval granted + // to the first binding matched the second binding's artifacts, with nobody + // acting — the §9 inheritance hole this branch closes for exposure, arriving + // on the artifact path instead. + // + // The installation names a target (one continuous period of one binding's + // presence, never reused), the target names the binding, and a target closed + // by its tombstone refuses here rather than reading as live. So a + // replacement binding under the same alias is a different binding id and + // inherits nothing. + if bindingErr := s.requireConsentedBinding(ctx, installation.TargetId, policy.Sources[req.Identity.Source]); bindingErr != nil { + return bindingErr + } permission := policy.Run if action == "approve" { permission = policy.Activate @@ -282,6 +305,39 @@ func (s *Service) DecideExecutableArtifact(ctx context.Context, caller ModuleCal return out, err } +// requireConsentedBinding refuses unless the installation's consented target is +// still live AND records exactly the binding the policy installed. +// +// The read runs on the CONTROL PLANE, not in the caller's organisation +// transaction, and that is the whole subtlety. `solution_targets` is a global +// relation with exact grants: the host's presence state spans every +// organisation, so the request-path role holds no SELECT on it and the first +// version of this guard failed with "permission denied for table +// solution_targets". Granting request traffic that read is the thing +// installations.proto explicitly rules out — answering about one organisation +// would mean letting request traffic read every organisation's presence. +// +// A denied read would also have been the dangerous shape rather than a loud +// one if the query had been a count: permission denied reads as empty. It is a +// single row fetched by primary key, so it errors. +func (s *Service) requireConsentedBinding(ctx context.Context, targetID, installedBinding string) error { + if targetID == "" || installedBinding == "" { + return status.Error(codes.PermissionDenied, "artifact installation unavailable") + } + var target *SolutionTarget + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var e error + target, e = s.store.GetSolutionTarget(ctx, targetID) + return e + }); err != nil { + return err + } + if !target.Live() || target.BindingID != installedBinding { + return status.Error(codes.PermissionDenied, "artifact installation unavailable") + } + return nil +} + // RevokeApprovedExecutableArtifact addresses the retained row, so disabling or // changing a policy/installation cannot make old consent impossible to revoke. // Scope and permission come from that row's original host policy, never the diff --git a/module/services/accounts/code/pkg/business/executable_artifacts_test.go b/module/services/accounts/code/pkg/business/executable_artifacts_test.go index a14470f1a..a7753a452 100644 --- a/module/services/accounts/code/pkg/business/executable_artifacts_test.go +++ b/module/services/accounts/code/pkg/business/executable_artifacts_test.go @@ -12,8 +12,16 @@ import ( "github.com/stretchr/testify/require" ) +// artifactTestBindingID is the delivered presence binding the policy installs. +// It is a BINDING id and not the route alias "acme/example": an alias is +// reusable by a later binding, so keying the policy on one would let a +// replacement inherit this binding's approvals — +// TestAReplacementBindingUnderTheSameAliasInheritsNoArtifactApproval is the +// demonstration. +const artifactTestBindingID = "binding-acme-example-0001" + func artifactTestPolicy() ExecutableArtifactPolicy { - return ExecutableArtifactPolicy{Schema: "example.artifact/v1", Sources: map[string]string{"acme.example": "acme/example"}, Activate: ArtifactPermission{"definitions", "configure"}, Run: ArtifactPermission{"definitions", "run"}, Contracts: []ArtifactContract{{"model", "example/chat", "sha256:" + strings.Repeat("a", 64)}, {"policy", "example/bounded", "sha256:" + strings.Repeat("b", 64)}}, RequiredKinds: []string{"model", "policy"}} + return ExecutableArtifactPolicy{Schema: "example.artifact/v1", Sources: map[string]string{"acme.example": artifactTestBindingID}, Activate: ArtifactPermission{"definitions", "configure"}, Run: ArtifactPermission{"definitions", "run"}, Contracts: []ArtifactContract{{"model", "example/chat", "sha256:" + strings.Repeat("a", 64)}, {"policy", "example/bounded", "sha256:" + strings.Repeat("b", 64)}}, RequiredKinds: []string{"model", "policy"}} } func artifactTestRequest() ExecutableArtifactRequest { p := artifactTestPolicy() @@ -54,7 +62,16 @@ func TestExecutableArtifactIdentityAndPolicy(t *testing.T) { _, changed, err := artifactEnvelope(caller, caller.BoundOrg, req, p) require.NoError(t, err) require.NotEqual(t, digest, changed, "policy changes invalidate approval") - raw, _ := json.Marshal(map[string]any{"example": map[string]any{"tenant": caller.BoundOrg, "artifact_policies": map[string]ExecutableArtifactPolicy{"definitions": p}}}) + // The workload is required on every registry entry — execution binding is + // unconditional, so an entry without one is a composition that cannot + // authenticate at all and ParseModulePrincipalRegistry refuses it at boot. + // It is here because this test parses a REAL registry document, not because + // artifact policy needs it. + raw, _ := json.Marshal(map[string]any{"example": map[string]any{ + "tenant": caller.BoundOrg, + "artifact_policies": map[string]ExecutableArtifactPolicy{"definitions": p}, + "workload": ModuleWorkload{ServiceAccount: "example", Namespace: "example-system", Container: "example"}, + }}) registry, err := ParseModulePrincipalRegistry(string(raw)) require.NoError(t, err) require.Equal(t, p, registry[ModulePrincipalID("example")].ArtifactPolicies["definitions"]) @@ -67,11 +84,15 @@ type artifactDecisionStore struct { InstallationStore rows map[string]*ExecutableArtifactApproval installation *gen.Installation - member bool - admin bool - permission bool - puts int - tenant string + // targets is the host's presence state the guard resolves through: the + // installation names a target id, the target names the binding. Keyed by + // target id, exactly as GetSolutionTarget is asked. + targets map[string]*SolutionTarget + member bool + admin bool + permission bool + puts int + tenant string } func (s *artifactDecisionStore) WithOrgTx(ctx context.Context, tenant string, fn func(context.Context) error) error { @@ -81,6 +102,15 @@ func (s *artifactDecisionStore) WithOrgTx(ctx context.Context, tenant string, fn func (s *artifactDecisionStore) GetInstallation(context.Context, string, string) (*gen.Installation, gen.InstallationHealth, error) { return s.installation, gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, nil } + +// WithControlPlane runs the body as the control plane. The target read is +// control-plane because solution_targets is global with exact grants. +func (s *artifactDecisionStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} +func (s *artifactDecisionStore) GetSolutionTarget(_ context.Context, targetID string) (*SolutionTarget, error) { + return s.targets[targetID], nil +} func (s *artifactDecisionStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { if !s.member { return nil, nil @@ -118,10 +148,19 @@ func TestExecutableArtifactConsentNeverInferredFromRun(t *testing.T) { req := artifactTestRequest() tenant := uuid.NewString() caller := ModuleCaller{PrincipalID: ModulePrincipalID("example"), BoundOrg: tenant} - store := &artifactDecisionStore{rows: map[string]*ExecutableArtifactApproval{}, member: true, admin: true, permission: true, installation: &gen.Installation{Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, SolutionIdentifier: "acme/example"}} + target := uuid.NewString() + store := &artifactDecisionStore{ + rows: map[string]*ExecutableArtifactApproval{}, + member: true, + admin: true, + permission: true, + installation: &gen.Installation{Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, TargetId: target}, + targets: map[string]*SolutionTarget{target: {ID: target, BindingID: artifactTestBindingID, SolutionID: "acme/example"}}, + } s, err := NewService(store) require.NoError(t, err) - s.modulePrincipals = ModulePrincipalRegistry{caller.PrincipalID: {Prefix: "example", Tenant: tenant, ArtifactPolicies: map[string]ExecutableArtifactPolicy{"definitions": artifactTestPolicy()}}} + registry := ModulePrincipalRegistry{caller.PrincipalID: {Prefix: "example", Tenant: tenant, ArtifactPolicies: map[string]ExecutableArtifactPolicy{"definitions": artifactTestPolicy()}}} + s.modulePrincipals.Store(®istry) decide := func(action string) (*ExecutableArtifactApproval, error) { return s.DecideExecutableArtifact(context.Background(), caller, tenant, uuid.NewString(), "example", action, req) } @@ -146,10 +185,10 @@ func TestExecutableArtifactConsentNeverInferredFromRun(t *testing.T) { _, err = decide("authorize") require.Error(t, err) store.permission = true - store.installation.SolutionIdentifier = "foreign" + store.targets[store.installation.TargetId].BindingID = "binding-foreign-0002" _, err = decide("authorize") require.Error(t, err) - store.installation.SolutionIdentifier = "acme/example" + store.targets[store.installation.TargetId].BindingID = artifactTestBindingID store.admin = false _, err = decide("authorize") require.NoError(t, err, "tenant consent survives approver departure; current run authority still required") @@ -188,3 +227,74 @@ func (s *artifactDecisionStore) As(id Identity) Scoped { func (s *artifactDecisionStoreScope) Within(ctx context.Context, fn func(context.Context) error) error { return s.store.WithOrgTx(ctx, s.id.OrgID, fn) } + +// TestAReplacementBindingUnderTheSameAliasInheritsNoArtifactApproval is the §9 +// inheritance hole, on the artifact path. +// +// The guard this exercises compared `installation.SolutionIdentifier` against +// the policy's installed source — a free-text ROUTE ALIAS. An alias is +// deliberately reusable: a binding is withdrawn, its presence tombstoned, and a +// different binding may then take the same route. Under the alias comparison +// the replacement's artifacts matched the approval the FIRST binding was +// granted, with nobody acting. +// +// Keyed on the binding id, the replacement is a different binding and matches +// nothing. Both halves are asserted, because only the pair is the property: the +// first binding still passes (so this is not a test that refuses everything), +// and the replacement under the identical alias is refused. +func TestAReplacementBindingUnderTheSameAliasInheritsNoArtifactApproval(t *testing.T) { + const alias = "acme/example" + req := artifactTestRequest() + tenant := uuid.NewString() + caller := ModuleCaller{PrincipalID: ModulePrincipalID("example"), BoundOrg: tenant} + + // One organisation, one installation, one route alias — and two periods of + // presence under it, the second belonging to a different binding. + firstTarget, replacementTarget := uuid.NewString(), uuid.NewString() + closed := uint64(7) + store := &artifactDecisionStore{ + rows: map[string]*ExecutableArtifactApproval{}, + member: true, + admin: true, + permission: true, + installation: &gen.Installation{Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, TargetId: firstTarget}, + targets: map[string]*SolutionTarget{ + firstTarget: {ID: firstTarget, BindingID: artifactTestBindingID, SolutionID: alias}, + // The replacement: the SAME alias, a different binding, its own + // never-reused identity. + replacementTarget: {ID: replacementTarget, BindingID: "binding-newcomer-0002", SolutionID: alias}, + }, + } + s, err := NewService(store) + require.NoError(t, err) + registry := ModulePrincipalRegistry{caller.PrincipalID: { + Prefix: "example", Tenant: tenant, + ArtifactPolicies: map[string]ExecutableArtifactPolicy{"definitions": artifactTestPolicy()}, + }} + s.modulePrincipals.Store(®istry) + decide := func(action string) (*ExecutableArtifactApproval, error) { + return s.DecideExecutableArtifact(context.Background(), caller, tenant, uuid.NewString(), "example", action, req) + } + + // The binding the operator actually installed is approved, and may run. + approved, err := decide("approve") + require.NoError(t, err) + require.NotEmpty(t, approved.ID) + require.NoError(t, func() error { _, e := decide("authorize"); return e }()) + + // The binding is withdrawn: its target is tombstoned, never deleted. + store.targets[firstTarget].ClosedGeneration = &closed + _, err = decide("authorize") + require.Error(t, err, "a withdrawn presence is not a current installation") + require.ErrorContains(t, err, "artifact installation unavailable") + + // A different binding takes the identical route alias, and the + // installation is moved onto its target — the inheritance the old guard + // could not see, because the alias it compared is the same string. + store.installation.TargetId = replacementTarget + require.Equal(t, alias, store.targets[replacementTarget].SolutionID, + "the replacement must carry the SAME alias or this test proves nothing") + _, err = decide("authorize") + require.Error(t, err, "a replacement binding must not inherit the withdrawn binding's approval") + require.ErrorContains(t, err, "artifact installation unavailable") +} diff --git a/module/services/accounts/code/pkg/business/fixture_collection_test.go b/module/services/accounts/code/pkg/business/fixture_collection_test.go index 5a4258498..ab9602967 100644 --- a/module/services/accounts/code/pkg/business/fixture_collection_test.go +++ b/module/services/accounts/code/pkg/business/fixture_collection_test.go @@ -58,6 +58,8 @@ collections: // by; the host names none of its own, so without this the fixture's grants // confer nothing and the read set is empty by design. previous := testService.ModulePrincipals() + testService.SetModuleAuthorityReads(currentModuleAuthority{}, nil) + testService.SetModuleAuthorityReads(currentModuleAuthority{}, nil) testService.SetModulePrincipals(business.ModulePrincipalRegistry{ business.ModulePrincipalID("example"): { Prefix: "example", diff --git a/module/services/accounts/code/pkg/business/installation_target_identity_integration_test.go b/module/services/accounts/code/pkg/business/installation_target_identity_integration_test.go new file mode 100644 index 000000000..da9c0889c --- /dev/null +++ b/module/services/accounts/code/pkg/business/installation_target_identity_integration_test.go @@ -0,0 +1,180 @@ +//go:build !pure + +package business_test + +import ( + "context" + "testing" + "time" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "accounts/pkg/infra/storetx" + + "github.com/stretchr/testify/require" +) + +// Migration 23's two triggers, exercised. +// +// `installations.target_id` replaced a free-text `solution_identifier`, and the +// invariants that make the target an IDENTITY rather than a label are enforced in +// the DATABASE rather than in each writer. Until these tests existed, both +// triggers were present and unexercised — the column was written and audited, so +// every passing test was consistent with the triggers never firing at all. +// +// Enforced in the database on purpose: a check in the install path binds that +// path only, and an installation row is also written by migrations, by the +// reconciler's revoke pass and by any future admin tool. "No writer forgets" is +// a promise a trigger keeps and a convention does not. + +// A target cannot be repointed once set. +// +// This is what makes the target the installation's identity: if it could move, +// the row would be a mutable pointer and "which solution is this installation +// for" would have no durable answer — a consent record that can be reassigned is +// not a consent record. +func TestInstallationTargetCannotBeRepointed(t *testing.T) { + clearData(t) + ctx := testCtx + adminID, orgID := mustUserAndOrg(t, ctx, "repoint@example.com", "repoint", "Repoint Co") + roleID := seedInstallableRole(t, ctx, orgID) + + targetID, _ := declarePresence(t, "first-solution") + otherTargetID, _ := declarePresence(t, "second-solution") + + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/first:1.0.0", + TargetID: targetID, + RootScopeLabel: "First Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + + // Repointing at ANOTHER live target is refused. The other target is live, so + // nothing here is explained by the liveness trigger below. + err = testStore.WithControlPlane(ctx, func(ctx context.Context) error { + _, execErr := storetx.Tx(ctx).Exec(ctx, + `UPDATE public.installations SET target_id = $1 WHERE id = $2::uuid`, + otherTargetID, installation.Id) + return execErr + }) + require.Error(t, err, "an installation's target must not be repointable") + require.Contains(t, err.Error(), "immutable", + "the refusal must name the invariant, so a writer learns why rather than that an UPDATE failed") + + // Clearing it is refused too, which is the quieter half: a NULL target on an + // active row would satisfy "not repointed" while erasing what the + // installation consents to. + err = testStore.WithControlPlane(ctx, func(ctx context.Context) error { + _, execErr := storetx.Tx(ctx).Exec(ctx, + `UPDATE public.installations SET target_id = NULL WHERE id = $1::uuid`, installation.Id) + return execErr + }) + require.Error(t, err, "an installation's target must not be clearable") + + // And the control: an unrelated column on the same row still updates, so the + // refusals above are the trigger and not a privilege or locking failure that + // would block every write. + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + _, execErr := storetx.Tx(ctx).Exec(ctx, + `UPDATE public.installations SET co_owner_principal_ids = $1 WHERE id = $2::uuid`, + []string{adminID}, installation.Id) + return execErr + })) + + // The application cannot REMOVE the invariant either, and that is the + // property that makes enforcing it in the database worth more than + // enforcing it in the install path. + // + // I found this by trying to mutate the trigger away to prove it was what + // refused: the control-plane role is not the table's owner, so PostgreSQL + // answers `must be owner of relation installations`. So the enforcement sits + // BENEATH the privilege level every application path runs at — a compromise + // of this service cannot lift it, and neither can a careless migration run + // as the app role. + err = testStore.WithControlPlane(ctx, func(ctx context.Context) error { + _, execErr := storetx.Tx(ctx).Exec(ctx, + `DROP TRIGGER installations_target_immutable ON public.installations`) + return execErr + }) + require.Error(t, err, "the application role must not be able to drop the invariant it is bound by") + require.Contains(t, err.Error(), "must be owner", + "the refusal must be ownership, not a missing trigger — a DROP that succeeded quietly would leave the suite green and the invariant gone") +} + +// An active installation cannot name a withdrawn target. +// +// Presence is withdrawn by a tombstone generation that closes the target. An +// installation left active against a closed target would be consent to something +// the host no longer serves, and the next capability minted for it would name a +// solution that is gone. +func TestActiveInstallationCannotNameAWithdrawnTarget(t *testing.T) { + clearData(t) + ctx := testCtx + adminID, orgID := mustUserAndOrg(t, ctx, "withdrawn@example.com", "withdrawn", "Withdrawn Co") + roleID := seedInstallableRole(t, ctx, orgID) + + targetID, alias := declarePresence(t, "withdrawn-solution") + + // Close the target, as a tombstone generation does. + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + return testStore.CloseSolutionTarget(ctx, targetID, 2, time.Now().UTC()) + })) + + _, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/withdrawn:1.0.0", + TargetID: targetID, + RootScopeLabel: "Withdrawn Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.Error(t, err, "installing against a withdrawn target must be refused") + + // A target that never existed is refused too, and distinctly: an unknown id + // is not the same fact as a withdrawn one, and a trigger that reported them + // alike would hide a typo as a withdrawal. + _, err = testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/ghost:1.0.0", + TargetID: business.NewIDString(), + RootScopeLabel: "Ghost Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.Error(t, err, "installing against an unknown target must be refused") + + // The control: a LIVE target installs, so the refusals above are liveness and + // not the install path being broken for every input. + liveTargetID, _ := declarePresence(t, "live-solution") + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/live:1.0.0", + TargetID: liveTargetID, + RootScopeLabel: "Live Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + require.NotEmpty(t, installation.Id) + require.NotEqual(t, alias, "", "the withdrawn alias is referenced so the fixture is not silently unused") +} + +// seedInstallableRole creates the contributed role an install needs. +func seedInstallableRole(t *testing.T, ctx context.Context, orgID string) string { + t.Helper() + roleID := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(ctx, orgID, func(ctx context.Context) error { + return testStore.CreateRole(ctx, &gen.Role{ + Id: roleID, + Name: "installable role " + roleID, + Description: "least-privilege contributed role", + OrgId: orgID, + Permissions: []*gen.Permission{{Resource: "doc", Action: "write"}}, + }) + })) + return roleID +} diff --git a/module/services/accounts/code/pkg/business/installations.go b/module/services/accounts/code/pkg/business/installations.go index 980c4e20e..876e738be 100644 --- a/module/services/accounts/code/pkg/business/installations.go +++ b/module/services/accounts/code/pkg/business/installations.go @@ -2,6 +2,7 @@ package business import ( "context" + "errors" "fmt" gen "accounts/pkg/gen/saas/accounts/v1" @@ -17,8 +18,17 @@ type InstallSolutionParams struct { InstallerPrincipalID string OrgID string AgentIdentifier string // "publisher/name:version" - SolutionIdentifier string - DisplayName string // empty defaults to AgentIdentifier + // TargetID is the immutable solution target being installed — one continuous + // period of one binding's presence (solution_targets.go). An installer names + // an identity, never a route alias: an alias is reusable, so installing by + // alias installs whatever holds it now and whatever takes it later. + TargetID string + // RouteAlias is the alias the target currently serves, resolved from the + // target by the service before the transaction. DISPLAY ONLY — it is the + // default label of the authority-root scope node and appears in the audit + // payload. Nothing joins on it and nothing is authorised by it. + RouteAlias string + DisplayName string // empty defaults to AgentIdentifier // RootScopeLabel is the display label of the kind='solution' node. The node's // ltree path is derived server-side from its id (ADR-0002), never caller-chosen. RootScopeLabel string @@ -54,6 +64,11 @@ type InstallationStore interface { // The bool reports whether this call actually flipped an active installation // to revoked, so the caller emits the audit event exactly once. UninstallSolution(ctx context.Context, orgID, installationID string) (*gen.Installation, bool, error) + // ListInstallations returns one page of an org's installations with their live + // health, ordered on the keyset the cursor advances. limit is the page size the + // caller wants PLUS one: the extra row is how the caller detects a further page + // without a second count query, exactly as ListAccessibleScopes does. + ListInstallations(ctx context.Context, orgID string, status gen.InstallationStatus, pageToken string, limit int) ([]*gen.InstallationSummary, error) // ListCatalogueInstallations reads every organization's active // installations for the platform Catalogue (platform_catalogue.go). Unlike // the methods above it runs under the control plane, not an org transaction. @@ -73,7 +88,7 @@ func (s *Service) installationStore() InstallationStore { func (s *Service) InstallSolution(ctx context.Context, actorID string, params *InstallSolutionParams) (*gen.Installation, error) { w := wool.Get(ctx).In("InstallSolution", wool.Field("org_id", params.OrgID), - wool.Field("solution", params.SolutionIdentifier)) + wool.Field("target_id", params.TargetID)) params.GrantedBy = actorID if params.OwnerPrincipalID == "" { params.OwnerPrincipalID = actorID @@ -88,9 +103,35 @@ func (s *Service) InstallSolution(ctx context.Context, actorID string, params *I ErrTypeValidation, ) } - // Resolved before the transaction: the lookup runs as System, and calling it - // inside a tenant transaction would reuse that transaction instead, quietly - // downgrading an agent actor to "user". + // The target is resolved before the transaction, for the same reason the + // actor type is: it is control-plane state, and reading it inside the tenant + // transaction would reuse that transaction, where the request role holds no + // grant on the presence relations and the read would return nothing — a + // denied read printing as "no such target". + // + // Resolving is not the enforcement. A tombstone can be applied between this + // read and the insert, so liveness is enforced in the database by + // `installations_target_live_on_insert`, where the insert and the target row + // are in one transaction. What this read is for is the route alias (a display + // label) and a refusal that names the problem instead of surfacing a trigger. + alias, err := s.resolveInstallableTarget(ctx, params.TargetID) + if err != nil { + return nil, err + } + params.RouteAlias = alias + // The ceiling may only name audiences this host actually serves (issue #952). + // `allowed_audiences` used to be free text, so an installation could name a + // consumer that does not exist and then mint for it — the audience is what + // decides which consumer a capability is good at, so a value nothing serves is + // a capability pointing nowhere that still passes every other check. + // + // Refused HERE as well as narrowed at read, and both are needed: this catches + // a typo at the moment someone can fix it, and the read-time narrowing catches + // the set SHRINKING later, when delivery withdraws a solution long after any + // write. + if err := s.RequireHostAudiences(ctx, params.AllowedAudiences); err != nil { + return nil, NewStoreError(err, ErrTypeValidation) + } actorType := s.actorTypeForCreator(ctx, actorID) var installation *gen.Installation if err := s.store.WithOrgTx(ctx, params.OrgID, func(ctx context.Context) error { @@ -103,19 +144,19 @@ func (s *Service) InstallSolution(ctx context.Context, actorID string, params *I // boundary is the solution scope node the install just composed. if e := s.publishLifecycleEvent(ctx, EventInstallationCreated, params.OrgID, installation.GetRootScopeNodeId(), actorID, map[string]any{ - "installation_id": installation.GetId(), - "agent_principal_id": installation.GetAgentPrincipalId(), - "solution_identifier": params.SolutionIdentifier, + "installation_id": installation.GetId(), + "agent_principal_id": installation.GetAgentPrincipalId(), + "target_id": params.TargetID, }); e != nil { return e } return s.emitTx(ctx, actorID, actorType, EventInstallationCreated, "installation", installation.Id, params.OrgID, map[string]any{ - "agent_principal_id": installation.AgentPrincipalId, - "solution_identifier": params.SolutionIdentifier, - "role_id": params.RoleID, - "allowed_audiences": params.AllowedAudiences, - "allowed_scopes": params.AllowedScopes, + "agent_principal_id": installation.AgentPrincipalId, + "target_id": params.TargetID, + "role_id": params.RoleID, + "allowed_audiences": params.AllowedAudiences, + "allowed_scopes": params.AllowedScopes, }) }); err != nil { return nil, w.Wrapf(err, "cannot install solution") @@ -148,6 +189,49 @@ func (s *Service) GetInstallation(ctx context.Context, orgID, installationID str return installation, health, nil } +// listInstallationsDefaultPageSize / …MaxPageSize bound the listing. The max +// mirrors the proto ceiling; the default is generous because the caller this +// exists for — a solution projection — wants an organization's whole installed +// set and would otherwise page for it on every menu read. +const ( + listInstallationsDefaultPageSize = 200 + listInstallationsMaxPageSize = 500 +) + +// ListInstallations enumerates one organization's installations with the health +// resolved live beside each, so a caller rendering an installed solution as +// unavailable does not need a GetInstallation per row. Always org-scoped, so it +// runs under WithOrgTx and RLS confines it to that tenant. Cursor-paginated with +// the same over-fetch-one idiom as ListAccessibleScopes. +func (s *Service) ListInstallations(ctx context.Context, req *gen.ListInstallationsRequest) (*gen.ListInstallationsResponse, error) { + w := wool.Get(ctx).In("ListInstallations", wool.Field("org_id", req.GetOrgId())) + pageSize := int(req.GetPageSize()) + if pageSize <= 0 { + pageSize = listInstallationsDefaultPageSize + } + if pageSize > listInstallationsMaxPageSize { + pageSize = listInstallationsMaxPageSize + } + + var summaries []*gen.InstallationSummary + if err := s.store.WithOrgTx(ctx, req.GetOrgId(), func(ctx context.Context) error { + out, e := s.installationStore().ListInstallations(ctx, req.GetOrgId(), req.GetStatus(), req.GetPageToken(), pageSize+1) + summaries = out + return e + }); err != nil { + return nil, w.Wrapf(err, "cannot list installations") + } + + var nextToken string + if len(summaries) > pageSize { + summaries = summaries[:pageSize] + // The cursor is the last RETURNED row's key, not the over-fetched row's: + // the next page must resume at the row after the one the caller saw. + nextToken = summaries[pageSize-1].GetInstallation().GetId() + } + return &gen.ListInstallationsResponse{Installations: summaries, NextPageToken: nextToken}, nil +} + // TransferInstallationOwnership reassigns the owner of record and replaces the // co-owner succession set. The new owner must be a current org admin; the store // fails closed otherwise. @@ -175,12 +259,28 @@ func (s *Service) TransferInstallationOwnership(ctx context.Context, actorID, or // its standing grant, and marks the installation revoked. The solution scope node // is left in place (inert without a grant or a live agent) so a reinstall reuses // it. Idempotent on an already-revoked installation (no second audit event). +// +// It is a NARROWING, so it runs under the policy log: the entry is appended to +// the external record and its receipt is written here BEFORE anything is +// revoked, and the revocation commits in the same transaction as the receipt's +// commit. A host that cannot witness the append refuses the uninstall rather +// than performing one a restore could silently undo. +// +// The transaction is the policy log's CONTROL-PLANE one rather than this +// organisation's, because the receipt lives in a control-plane relation that +// app_tenant holds no grant on — and the receipt and the revocation have to be +// the same transaction or the protocol's one forbidden state ("applied but +// unwitnessed") becomes reachable. The organisation's own scoping is not lost: +// every statement below names orgID explicitly, which is what the tenant policy +// would have checked. func (s *Service) UninstallSolution(ctx context.Context, actorID, orgID, installationID string) error { w := wool.Get(ctx).In("UninstallSolution", wool.Field("installation_id", installationID)) actorType := s.actorTypeForCreator(ctx, actorID) - if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { - return s.uninstallSolutionTx(ctx, actorID, actorType, orgID, installationID) - }); err != nil { + if err := s.WithPolicyLoggedNarrowing(ctx, + uninstallPolicyLogEntry(actorID, orgID, installationID), + func(ctx context.Context) error { + return s.uninstallSolutionTx(ctx, actorID, actorType, orgID, installationID) + }); err != nil { return w.Wrapf(err, "cannot uninstall solution") } return nil @@ -201,13 +301,96 @@ func (s *Service) uninstallSolutionTx(ctx context.Context, actorID, actorType, o } if err := s.publishLifecycleEvent(ctx, EventInstallationRevoked, orgID, installation.GetRootScopeNodeId(), actorID, map[string]any{ - "installation_id": installationID, - "solution_identifier": installation.GetSolutionIdentifier(), + "installation_id": installationID, + "target_id": installation.GetTargetId(), }); err != nil { return err } return s.emitTx(ctx, actorID, actorType, EventInstallationRevoked, "installation", installationID, orgID, map[string]any{ - "solution_identifier": installation.SolutionIdentifier, + "target_id": installation.GetTargetId(), }) } + +// ErrSolutionTargetNotInstallable reports that a target cannot be installed: it +// does not exist, it has been withdrawn, or its binding's newest applied +// generation is not a present one. +var ErrSolutionTargetNotInstallable = errors.New("solution target is not installable") + +// resolveInstallableTarget confirms a target is live and ACCEPTED, and returns +// the route alias it currently serves. +// +// Accepted means the host has an applied, non-tombstone generation for the +// target's binding. A target whose newest DESIRED generation was refused is +// still installable at the release that WAS applied, which is the correct answer: +// the host is serving that release, and refusing the install would make a bad +// delivery pipeline run look like a withdrawn solution. +// +// It reads through the same catalogue query the listing serves, so there is no +// second predicate that means to agree with it — an administrator cannot install +// something the catalogue would not have offered. +func (s *Service) resolveInstallableTarget(ctx context.Context, targetID string) (string, error) { + if targetID == "" { + return "", NewStoreError( + fmt.Errorf("%w: no target was named", ErrSolutionTargetNotInstallable), + ErrTypeValidation) + } + var alias string + var found bool + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + // The SAME query the catalogue listing serves, narrowed to one target. + // One statement, one acceptance predicate: an administrator cannot + // install something the catalogue would not have offered, and there is + // no second predicate here that means to agree with that one. + available, err := s.store.ListAvailableSolutionTargets(ctx, + AvailableSolutionQuery{TargetID: targetID, Limit: 1}) + if err != nil { + return err + } + if len(available) == 1 { + alias, found = available[0].RouteAlias, true + } + return nil + }); err != nil { + return "", err + } + if !found { + return "", NewStoreError( + fmt.Errorf("%w: %s is withdrawn, unknown, or has no applied present generation", + ErrSolutionTargetNotInstallable, targetID), + ErrTypeValidation) + } + return alias, nil +} + +// ListAvailableSolutions is the catalogue an administrator installs from: +// accepted applied state, never the diagnostic binding listing. +// +// It pages exactly as ListInstallations does — over-fetch one row and use the +// last RETURNED row's key as the cursor — so the two listings an administration +// screen reads side by side behave the same way. +func (s *Service) ListAvailableSolutions( + ctx context.Context, orgID, pageToken string, pageSize int, +) ([]*AvailableSolutionTarget, string, error) { + if pageSize <= 0 { + pageSize = listInstallationsDefaultPageSize + } + if pageSize > listInstallationsMaxPageSize { + pageSize = listInstallationsMaxPageSize + } + var available []*AvailableSolutionTarget + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + available, err = s.store.ListAvailableSolutionTargets(ctx, + AvailableSolutionQuery{OrgID: orgID, Cursor: pageToken, Limit: pageSize + 1}) + return err + }); err != nil { + return nil, "", err + } + next := "" + if len(available) > pageSize { + available = available[:pageSize] + next = available[pageSize-1].TargetID + } + return available, next, nil +} diff --git a/module/services/accounts/code/pkg/business/installations_integration_test.go b/module/services/accounts/code/pkg/business/installations_integration_test.go index 15d0b453e..de89f895c 100644 --- a/module/services/accounts/code/pkg/business/installations_integration_test.go +++ b/module/services/accounts/code/pkg/business/installations_integration_test.go @@ -33,14 +33,27 @@ func TestInstallSolutionEmitsAuditWithGrantorAndCeiling(t *testing.T) { }) })) + targetID, _ := declarePresence(t, "acme-solution") + // The ceiling may only name audiences this host SERVES (issue #952): an + // audience outside the derived vocabulary is refused at install, because a + // ceiling naming a consumer that does not exist grants a capability pointing + // nowhere. `acme.collection` is a declared MODULE prefix, which is the set's + // member a forwarded viewer context names — declared here the way a + // composition declares it, through the principal registry. + previousPrincipals := testService.ModulePrincipals() + testService.SetModulePrincipals(business.ModulePrincipalRegistry{ + business.ModulePrincipalID("acme.collection"): {Prefix: "acme.collection"}, + }) + t.Cleanup(func() { testService.SetModulePrincipals(previousPrincipals) }) + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/solution:1.0.0", - SolutionIdentifier: "acme.example/solution", - RootScopeLabel: "Acme Solution", - RoleID: roleID, - AllowedAudiences: []string{"acme.collection"}, - AllowedScopes: []string{"doc"}, + OrgID: orgID, + AgentIdentifier: "acme.example/solution:1.0.0", + TargetID: targetID, + RootScopeLabel: "Acme Solution", + RoleID: roleID, + AllowedAudiences: []string{"acme.collection"}, + AllowedScopes: []string{"doc"}, }) require.NoError(t, err) require.Equal(t, adminID, installation.OwnerPrincipalId, "owner of record defaults to the installing admin") @@ -57,6 +70,8 @@ func TestInstallSolutionEmitsAuditWithGrantorAndCeiling(t *testing.T) { require.Equal(t, adminID, entry.ActorID, "the grantor is on the audit event") require.Equal(t, installation.AgentPrincipalId, entry.Payload["agent_principal_id"]) require.Equal(t, roleID, entry.Payload["role_id"]) + require.Equal(t, targetID, entry.Payload["target_id"], + "the audit trail names the immutable target, not a reusable route alias") } // A malformed agent identifier is a boundary input: it must be rejected as a @@ -68,11 +83,12 @@ func TestInstallSolutionRejectsMalformedAgentIdentifier(t *testing.T) { ctx := testCtx adminID, orgID := mustUserAndOrg(t, ctx, "badagent@example.com", "badagent", "Bad Agent Co") + targetID, _ := declarePresence(t, "acme-solution") _, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "notacanonicalidentifier", // no '/' or ':' - SolutionIdentifier: "acme.example/solution", - RoleID: business.NewIDString(), + OrgID: orgID, + AgentIdentifier: "notacanonicalidentifier", // no '/' or ':' + TargetID: targetID, + RoleID: business.NewIDString(), }) require.Error(t, err) var se *business.StoreError diff --git a/module/services/accounts/code/pkg/business/introspection.go b/module/services/accounts/code/pkg/business/introspection.go index 0363d9260..e24158f4f 100644 --- a/module/services/accounts/code/pkg/business/introspection.go +++ b/module/services/accounts/code/pkg/business/introspection.go @@ -143,6 +143,9 @@ var rpcDescriptions = withWorkContextConsumerDescriptions(map[string]string{ "InstallationService/UninstallSolution": "Uninstall a solution and reverse its composition.", "InstallationService/TransferInstallationOwnership": "Reassign an installation's accountable owner of record.", "InstallationService/GetInstallation": "Get one installation and its live health.", + "InstallationService/ListInstallations": "List one organization's installations with their live health.", + "InstallationService/ListAvailableSolutions": "List the solution targets an administrator may install: accepted applied presence, never a refused desired generation.", + "SolutionEntitlementService/ListSolutionEntitlements": "List the installed solutions a subject may use in one organization.", "DelegationService/ListPendingDelegations": "List pending organization delegations.", "DelegationService/RequestDelegation": "Request a scoped authority delegation.", "DelegationService/WaitForDelegation": "Stream the terminal delegation decision.", @@ -274,9 +277,8 @@ var rpcDescriptions = withWorkContextConsumerDescriptions(map[string]string{ "SSOAdminService/GetSSO": "Read org SSO state.", "SSOAdminService/StartSetup": "Mint WorkOS portal link.", "ClientRegistryService/ListRegisteredClients": "Read the declared first-party clients the gateway resolves origins from.", - "SolutionRegistryService/PutSolutionRegistration": "Write or renew one half of a solution's durable runtime registration.", - "SolutionRegistryService/DeleteSolutionRegistration": "Deregister a solution and leave a tombstone that blocks resurrection.", "SolutionRegistryService/ListSolutionRegistrations": "Read the solution registry snapshot a replica rebuilds its cache from.", + "SolutionRegistryService/ListSolutionHostBindings": "Read the declared solution bindings: desired, applied and why they differ.", "TeamService/AddMember": "Add a user to a team.", "TeamService/CreateTeam": "Create a team within an org.", "TeamService/DeleteTeam": "Delete a team and its memberships.", diff --git a/module/services/accounts/code/pkg/business/layered_access.go b/module/services/accounts/code/pkg/business/layered_access.go index 383e59c18..3548185ff 100644 --- a/module/services/accounts/code/pkg/business/layered_access.go +++ b/module/services/accounts/code/pkg/business/layered_access.go @@ -19,6 +19,23 @@ const ( ScopeNodeKindCollection = "collection" ) +// The (resource_type, action) pair a solution-visibility question is asked in +// (issue #949). A subject may see an installed solution when it holds an +// accessible scope at that installation's authority-root node for this pair — +// resolved through the same grant + share union CheckAccess resolves, so a +// projection can never offer what an authority check would deny. +// +// Host-owned, exactly like the node kinds above: a solution install, its scope +// node and its registry record are all the host's, so naming the permission that +// governs *using* one names nothing above this module. A role that should reveal +// a solution permits (solution, use) or carries the wildcard; a role that permits +// only some product resource does not, which is what keeps a grant written for +// another purpose from quietly revealing a menu entry. +const ( + ResourceTypeSolution = "solution" + ActionUseSolution = "use" +) + // CheckAccess is the hierarchical + per-record authorization decision (#178), // the companion to CheckPermission. Always org-scoped — a record lives in // exactly one tenant — so it always runs under WithOrgTx. The store resolves the @@ -141,9 +158,22 @@ func (s *Service) GrantScope(ctx context.Context, actorID string, req *gen.Grant } // RevokeScope removes a hierarchical scope grant. +// +// A NARROWING, so it runs under the policy log: appended and receipted before +// the grant goes, and the delete commits in the same transaction as the +// receipt's commit. A host that cannot witness the append refuses rather than +// revoking unwitnessed — a revocation a restore could silently undo is worse +// than a refusal the caller can see. +// +// The transaction is the policy log's control-plane one, because the receipt +// relation is control-plane only and the two writes must be atomic. The +// statement below names req.OrgId explicitly, so the delete stays confined to +// the organisation the tenant policy would have confined it to. func (s *Service) RevokeScope(ctx context.Context, actorID string, req *gen.RevokeScopeRequest) error { w := wool.Get(ctx).In("RevokeScope") - if err := s.store.WithOrgTx(ctx, req.OrgId, func(ctx context.Context) error { + if err := s.WithPolicyLoggedNarrowing(ctx, revokeScopePolicyLogEntry( + actorID, req.OrgId, req.SubjectId, req.SubjectKind.String(), req.ScopePath, req.RoleId, + ), func(ctx context.Context) error { if e := s.store.RevokeScope(ctx, req.OrgId, req.SubjectId, req.SubjectKind, req.ScopePath, req.RoleId); e != nil { return e } @@ -193,9 +223,22 @@ func (s *Service) ShareRecord(ctx context.Context, actorID string, req *gen.Shar } // RevokeShare removes a per-record share. +// +// A NARROWING, so it runs under the policy log, exactly as RevokeScope above +// does: appended and receipted before the share goes, with the delete and the +// receipt's commit in one transaction, and a refusal rather than an unwitnessed +// revocation when the log cannot be reached. +// +// That transaction is the policy log's control-plane one, because the receipt +// relation is control-plane only and the two writes must be atomic. The delete +// names req.OrgId in its WHERE, so it stays confined to the organisation the +// tenant policy would have confined it to. func (s *Service) RevokeShare(ctx context.Context, actorID string, req *gen.RevokeShareRequest) error { w := wool.Get(ctx).In("RevokeShare") - if err := s.store.WithOrgTx(ctx, req.OrgId, func(ctx context.Context) error { + if err := s.WithPolicyLoggedNarrowing(ctx, revokeSharePolicyLogEntry( + actorID, req.OrgId, req.ResourceType, req.ResourceId, + req.SubjectId, req.SubjectKind.String(), req.RoleId, + ), func(ctx context.Context) error { if err := s.store.RevokeShare(ctx, req.OrgId, req.ResourceType, req.ResourceId, req.SubjectId, req.SubjectKind, req.RoleId); err != nil { return err } @@ -229,7 +272,7 @@ func (s *Service) ListCollectionAccess(ctx context.Context, req *gen.ListCollect var collections []*gen.CollectionAccess if err := s.store.WithOrgTx(ctx, req.OrgId, func(ctx context.Context) error { var err error - collections, err = s.store.ListCollectionAccess(ctx, req.OrgId, req.PageToken, size+1, s.modulePrincipals.ContentResources()) + collections, err = s.store.ListCollectionAccess(ctx, req.OrgId, req.PageToken, size+1, s.declaredModules().ContentResources()) return err }); err != nil { return nil, err diff --git a/module/services/accounts/code/pkg/business/live_module_authority_integration_test.go b/module/services/accounts/code/pkg/business/live_module_authority_integration_test.go new file mode 100644 index 000000000..a159ad576 --- /dev/null +++ b/module/services/accounts/code/pkg/business/live_module_authority_integration_test.go @@ -0,0 +1,109 @@ +//go:build !pure + +package business_test + +import ( + "testing" + + "accounts/pkg/business" + + "github.com/stretchr/testify/require" +) + +// The live authority read, over a REAL installation, through the real store. +// +// This test exists because its absence let a regression through that broke every +// module capability path in production. The read joined +// `installations.agent_principal_id` against the calling module principal — +// two different identity spaces, since an installation's agent principal is +// minted per installation with a fresh uuid while a module principal is a +// deterministic uuid from its declared prefix — so the join never matched and +// every capability answered PermissionDenied. It also ran on whatever pool the +// caller's context carried, which for the adapters is the request pool, where +// RLS hides `installations` entirely. +// +// Every test of the mechanism faked the read as always-current. A fake standing +// in for the thing under test proves the caller and never the thing, which is +// why this one goes through `testStore` against real PostgreSQL. +func TestLiveModuleAuthorityOverARealInstallation(t *testing.T) { + clearData(t) + ctx := testCtx + adminID, orgID := mustUserAndOrg(t, ctx, "liveauth@example.com", "liveauth", "LiveAuth Co") + roleID := seedInstallableRole(t, ctx, orgID) + targetID, _ := declarePresence(t, "liveauth-solution") + + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/liveauth:1.0.0", + TargetID: targetID, + RootScopeLabel: "LiveAuth Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + + // A module principal, which is NOT the installation's agent principal. The + // regression assumed these were the same value. + modulePrincipal := business.ModulePrincipalID("liveauth") + require.NotEqual(t, installation.AgentPrincipalId, modulePrincipal, + "the fixture must exercise the two distinct identity spaces, or it cannot catch the regression") + + // Naming NO installation must succeed and report the principal's epoch. + // The regression failed here, and failing here is what denied every real + // module caller: nothing relates a module principal to an installation, so + // "I could not find one" is not "it is revoked". + live, err := testStore.LiveModuleAuthority(ctx, modulePrincipal, orgID, "") + require.NoError(t, err, + "a capability naming no installation must not be refused: the term is unanswerable, not failed") + require.Empty(t, live.InstallationID) + + // Naming a LIVE installation resolves it. + live, err = testStore.LiveModuleAuthority(ctx, modulePrincipal, orgID, installation.Id) + require.NoError(t, err) + require.Equal(t, installation.Id, live.InstallationID) + + // Naming an installation in ANOTHER organisation is refused, not resolved — + // the read is scoped by org as well as by id. + _, otherOrgID := mustUserAndOrg(t, ctx, "other@example.com", "other", "Other Co") + _, err = testStore.LiveModuleAuthority(ctx, modulePrincipal, otherOrgID, installation.Id) + require.ErrorIs(t, err, business.ErrModuleInstallationInactive) + + // A malformed caller is refused BEFORE the query, so an empty organisation + // does not surface as a uuid cast error that reads like an outage and gets + // retried forever. + _, err = testStore.LiveModuleAuthority(ctx, modulePrincipal, "", "") + require.ErrorIs(t, err, business.ErrModuleAuthorityUnreadable) +} + +// A revoked installation that the capability names is refused. +// +// Separate from the test above so the refusal is attributable: that one proves +// the read resolves and scopes, this one proves revocation actually lands. +func TestLiveModuleAuthorityRefusesARevokedInstallation(t *testing.T) { + clearData(t) + ctx := testCtx + adminID, orgID := mustUserAndOrg(t, ctx, "revoked@example.com", "revoked", "Revoked Co") + roleID := seedInstallableRole(t, ctx, orgID) + targetID, _ := declarePresence(t, "revoked-solution") + + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/revoked:1.0.0", + TargetID: targetID, + RootScopeLabel: "Revoked Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + modulePrincipal := business.ModulePrincipalID("revoked") + + // Live first, so the refusal below is the uninstall and not the fixture. + _, err = testStore.LiveModuleAuthority(ctx, modulePrincipal, orgID, installation.Id) + require.NoError(t, err) + + require.NoError(t, testService.UninstallSolution(ctx, adminID, orgID, installation.Id)) + + _, err = testStore.LiveModuleAuthority(ctx, modulePrincipal, orgID, installation.Id) + require.ErrorIs(t, err, business.ErrModuleInstallationInactive, + "a capability naming an uninstalled solution must be refused on the next request") +} diff --git a/module/services/accounts/code/pkg/business/module_approved_build.go b/module/services/accounts/code/pkg/business/module_approved_build.go new file mode 100644 index 000000000..7d3b3c68a --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_approved_build.go @@ -0,0 +1,181 @@ +package business + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// The approved build, and the monotonicity contract core states but cannot +// enforce. +// +// Core's `SealSource.ApprovedBuild` documents the rule and says plainly that it +// compares values for EQUALITY and cannot detect a source that moves backwards — +// so keeping it is the implementer's job, which means this host's. The rule has +// two clauses and the second is the one that is easy to miss: +// +// 1. for one principal, the incarnation never DECREASES; +// 2. a change of DIGEST comes with an INCREASE in the incarnation. +// +// Core's own `MemorySealSource` missed the second: approve B at incarnation 5, +// then approve A again at incarnation 5, and every capability sealed to A at 5 — +// which the move to B revoked — verifies again. A swap-back at a fixed counter +// is the rewind the rule exists to prevent, reached through the field the rule +// did not cover. +// +// WHY A GUARD RATHER THAN CARE. The rule is a property of a sequence of writes, +// so no single write can be checked against the document it came from — only +// against what this host last served. A reviewer cannot verify care, and the +// next person to add a write path does not read this comment. So the guard +// refuses the write. +// +// WHERE THE VALUES COME FROM. The approved digest is the authority document's, +// which is signed and delivered out of band — the one input to the execution +// check that the caller has no influence over. This type does not fetch it; it +// holds what the delivery pipeline resolved, and enforces the ordering rule over +// it. + +// ErrApprovedBuildRewind reports a write that would move a principal's approved +// build backwards. +// +// A source that genuinely must rewind is reconstructing state rather than +// recording it, and belongs behind a fresh instance — which is core's own +// guidance and the reason this is a refusal rather than a clamp. Clamping would +// silently serve a value the writer did not intend, and the writer would never +// learn. +var ErrApprovedBuildRewind = errors.New("approved build would move backwards for this principal") + +// ApprovedBuildRecord is one principal's approved build. +type ApprovedBuildRecord struct { + Digest ApprovedDigest + Incarnation uint64 +} + +// MonotonicApprovedBuilds enforces the contract over whatever it is given. +// +// It is deliberately NOT a cache in front of a store: a cache would serve a +// stale answer when the store moved forward, and the whole point of the +// incarnation is that a capability sealed at an older one stops verifying. It +// holds the authoritative in-process view and refuses writes that violate the +// ordering. +type MonotonicApprovedBuilds struct { + mutex sync.RWMutex + records map[string]ApprovedBuildRecord + // known separates "this principal bears no approved build" from "this + // principal is unknown". Without it the two are the same missing map entry, + // and collapsing them is the dangerous direction: an unknown principal + // would mint an unbound capability, which is a capability for an identity + // the host has never heard of. + known map[string]bool +} + +// NewMonotonicApprovedBuilds returns an empty authority. +// +// Empty means every principal is UNKNOWN, not that every principal bears no +// build — so a host that has resolved nothing refuses rather than minting +// unbound capabilities for everyone. +func NewMonotonicApprovedBuilds() *MonotonicApprovedBuilds { + return &MonotonicApprovedBuilds{ + records: map[string]ApprovedBuildRecord{}, + known: map[string]bool{}, + } +} + +// Declare records that a principal exists, bearing no approved build yet. +// +// Separate from Approve because the two are different facts. A principal +// declared but not approved is a known identity that may act unbound if the +// capability it asks for permits it; an undeclared one is nothing at all. +func (m *MonotonicApprovedBuilds) Declare(principalID string) { + m.mutex.Lock() + defer m.mutex.Unlock() + m.known[principalID] = true +} + +// Approve records a principal's approved build, enforcing both clauses. +// +// The digest is compared as a DIGEST rather than as a string, so the same +// content written as a bare `sha256:…` and as a full reference is one digest and +// not a change — otherwise a re-delivery that merely respelled the reference +// would read as a new image and demand an incarnation bump it has no reason to +// have. +func (m *MonotonicApprovedBuilds) Approve(principalID string, record ApprovedBuildRecord) error { + if principalID == "" { + return fmt.Errorf("%w: no principal named", ErrApprovedBuildRewind) + } + if _, ok := digestPortion(string(record.Digest)); !ok { + // An approval is a statement about immutable content, so a value that + // is not a digest cannot be one. Refused here as well as at the + // comparison, because a non-digest stored now is a non-digest served to + // every later check. + return fmt.Errorf("%w: %q is not a digest, and only immutable content can be approved", + ErrApprovedBuildRewind, record.Digest) + } + if record.Incarnation == 0 { + // Core's schema requires `gte=1` when the pair is present, so a zero + // incarnation would produce a seal its own validation refuses — and it + // would do so at mint time, far from here. + return fmt.Errorf("%w: incarnation must be at least 1", ErrApprovedBuildRewind) + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + previous, had := m.records[principalID] + if had { + // Clause 1: never decreases. + if record.Incarnation < previous.Incarnation { + return fmt.Errorf("%w: incarnation %d is below the %d already served for %s", + ErrApprovedBuildRewind, record.Incarnation, previous.Incarnation, principalID) + } + // Clause 2, the one MemorySealSource missed: a change of digest must + // come with an INCREASE. Equal incarnations with different digests is + // exactly the swap-back, and it is the case a check on clause 1 alone + // lets through. + if record.Incarnation == previous.Incarnation && + !digestsEqual(string(record.Digest), string(previous.Digest)) { + return fmt.Errorf( + "%w: %s moves from %s to %s without advancing incarnation %d — "+ + "every capability sealed to the first digest at that incarnation would verify again", + ErrApprovedBuildRewind, principalID, + previous.Digest, record.Digest, record.Incarnation) + } + } + + m.records[principalID] = record + m.known[principalID] = true + return nil +} + +// ApprovedBuild answers what this host approves for a principal. +// +// Three states, each a different answer: +// +// - a record → the approved digest and its incarnation; +// - known, no record → ErrNoApprovedBuild. Refused by BindExecution: +// execution binding is unconditional, so there is nothing to mint. +// - unknown → ErrUnknownExecutionPrincipal. Refused. +// +// It takes NO attributes of the workload, which is core's rule and the reason it +// holds: resolving by (service account, image digest) would answer "approved" +// for whatever a pod presents, which lets a superseded generation in. The issuer +// answers what IT approves; the caller's running image is established +// independently; a mismatch is a refusal. +func (m *MonotonicApprovedBuilds) ApprovedBuild( + _ context.Context, principalID string, +) (ApprovedDigest, uint64, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + record, had := m.records[principalID] + if had { + return record.Digest, record.Incarnation, nil + } + if m.known[principalID] { + return "", 0, ErrNoApprovedBuild + } + return "", 0, ErrUnknownExecutionPrincipal +} + +var _ ExecutionAuthority = (*MonotonicApprovedBuilds)(nil) diff --git a/module/services/accounts/code/pkg/business/module_approved_build_reconcile.go b/module/services/accounts/code/pkg/business/module_approved_build_reconcile.go new file mode 100644 index 000000000..06d2c1db1 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_approved_build_reconcile.go @@ -0,0 +1,343 @@ +package business + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + + "github.com/codefly-dev/core/solutionhost" +) + +// Reconstructing the approved-build view from the delivered authority inbox, +// which is what makes execution-bound minting answerable in production. +// +// WHAT THIS CLOSES. `MonotonicApprovedBuilds` holds the authoritative view and +// enforces the ordering rule over it, and nothing wrote to it. Every principal +// was therefore UNKNOWN, which `ApprovedBuild` answers with +// ErrUnknownExecutionPrincipal — so `BindExecution` refused every caller and the +// whole execution-binding mechanism was unreachable from a running host. It was +// built, unit-tested and dead, which is the worst of the three states: a +// reviewer reads the tests and the guard and concludes the check is enforced. +// +// WHY RECONSTRUCTION RATHER THAN A WRITE PER DELIVERY. A withdrawal has to +// REMOVE an approved build, and `MonotonicApprovedBuilds` cannot: it has Approve +// and Declare and no third verb, by design — core's own guidance is that "a +// source that genuinely must rewind is reconstructing state rather than +// recording it, and belongs behind a fresh instance". So each pass builds a +// fresh view from the whole inbox and swaps it in. A tombstoned authority simply +// has no entry in the new view, and the principal goes back to UNKNOWN. +// +// AND WHY THE HIGH-WATER MARK SURVIVES THE SWAP. Applying that guidance naively +// loses the thing the guard exists for. A fresh instance holds no previous +// record, so between passes there is nothing for a rewind to be compared +// against: delivery could ship generation 4, then generation 3, and each pass +// would accept its own input as the first thing it had ever seen. The ordering +// rule is a property of the SEQUENCE OF PASSES, not of one pass, so the marks +// outlive the view they are used to build. Reconstruct the SET; keep the +// ORDERING. +// +// A TOMBSTONE MAKES A PRINCIPAL UNKNOWN, NOT UNBOUND. These are the two +// answers it would be easy to conflate, and only one of them is safe. +// `Declare` records "known, bears no execution", which mints a capability +// carrying no execution that every verifier accepts — the correct answer for a +// HUMAN session, and the wrong one for a module whose authority was withdrawn. +// So this reconciler never calls Declare: a principal is either approved for a +// build or absent, and absent is a refusal. The human path is not fed from this +// inbox at all. +// +// THE INCARNATION IS THE AUTHORITY DOCUMENT'S GENERATION, which core documents +// as "strictly monotonic per authority ID and starts at 1". That satisfies both +// clauses of the contract for free — a new approved build arrives as a new +// generation, so a change of digest necessarily advances the counter — and it +// avoids inventing a counter this host would then have to keep. +// +// PER AUTHORITY ID is the catch, and it is why the ambiguity check below exists. +// Two authority documents with different authority IDs have INDEPENDENT +// generation sequences, so if both name one principal their numbers are not +// comparable: the view would flap between them, and each flap either trips the +// rewind guard or — when the numbers happen to ascend — silently reseals the +// principal to the other document's build. A principal named by more than one +// live authority document is refused by name rather than resolved, for the same +// reason two live authority documents over one binding are. + +// ErrApprovedBuildAmbiguous reports a principal that more than one live +// authority document grants to, whose generations are therefore incomparable. +var ErrApprovedBuildAmbiguous = errors.New("more than one live authority document grants to this principal, so its approved build has no single generation") + +// ErrApprovedBuildInboxUnreadable reports that the pass could not establish the +// inbox, so no view was swapped in. +// +// Distinct from an empty view: nothing was judged. The previous view keeps +// serving, which is the same posture the presence reconciler takes for an +// unreadable mount — an unreadable inbox is not an empty desired set. +var ErrApprovedBuildInboxUnreadable = errors.New("the delivered authority inbox could not be established, so the approved-build view is unchanged") + +// ApprovedBuildReconciler rebuilds the approved-build view from the delivered +// authority inbox, keeping the ordering guarantee across passes. +// +// It is the ExecutionAuthority a host wires: reads go to whichever view the last +// successful pass installed, so a caller never observes a half-built one. +type ApprovedBuildReconciler struct { + activation *SolutionAuthorityActivation + + mutex sync.RWMutex + // view is what reads are served from. Never mutated in place: a pass + // builds a replacement and swaps the pointer, so a read either sees the + // whole previous pass or the whole new one. + view *MonotonicApprovedBuilds + // marks is the high-water mark per principal, and it is deliberately NOT + // part of the view. It records the highest incarnation this host has ever + // SERVED for a principal, so a later pass carrying a lower one is a + // refusal rather than a fresh start. It only grows; a principal whose + // authority is withdrawn keeps its mark, because re-delivering the + // withdrawn generation must not become acceptable again. + marks map[string]uint64 + // outcome is what the last pass established, and it affects only how an + // unestablished principal is REPORTED — never whether one is admitted. + outcome approvedBuildOutcome +} + +// NewApprovedBuildReconciler builds the reconciler over the activation reader. +// +// It takes the ACTIVATION reader rather than the delivery store directly, +// because that is the one place that re-verifies a delivered authority document +// against the host's current signer policy. Reading the store itself would make +// this a second answer to "which delivered documents does this host accept", and +// a signer removed from the allowlist would go on approving builds here after it +// had stopped being able to activate anything. +func NewApprovedBuildReconciler(activation *SolutionAuthorityActivation) (*ApprovedBuildReconciler, error) { + if activation == nil { + return nil, errors.New("the approved-build reconciler requires the authority activation reader, which owns document re-verification") + } + return &ApprovedBuildReconciler{ + activation: activation, + // An empty view, which answers ErrUnknownExecutionPrincipal for every + // principal. A host that has reconciled nothing refuses rather than + // minting unbound capabilities for everyone, and that is the state + // between process start and the first successful pass. + view: NewMonotonicApprovedBuilds(), + marks: map[string]uint64{}, + // outcome stays approvedBuildNoPassYet until RunOnce says otherwise, + // which is what lets a cold start be told apart from a principal this + // host has no declaration for. + outcome: approvedBuildNoPassYet, + }, nil +} + +// What the last reconciliation pass established, which decides how an +// unestablished principal is REPORTED (never whether it is admitted — all three +// refuse). +// +// Three states and not two, because "no answer yet" and "no question to answer" +// are different deployments: the first is a host between boot and its first +// pass, the second is a host with no ceiling delivered, which is correct and +// complete and whose principals genuinely are unknown forever. +type approvedBuildOutcome int + +const ( + approvedBuildNoPassYet approvedBuildOutcome = iota + approvedBuildNothingToAnswer + approvedBuildEstablished +) + +// ApprovedBuild serves from the installed view. +// +// The three answers are the view's: a record, ErrNoApprovedBuild for a known +// principal bearing none, ErrUnknownExecutionPrincipal otherwise. +func (r *ApprovedBuildReconciler) ApprovedBuild( + ctx context.Context, principalID string, +) (ApprovedDigest, uint64, error) { + r.mutex.RLock() + view, outcome := r.view, r.outcome + r.mutex.RUnlock() + if outcome == approvedBuildNoPassYet { + // Refused, as the empty view would have refused — but named for what it + // is. See ErrExecutionAuthorityUnreconciled. + return "", 0, fmt.Errorf("%w: %s", ErrExecutionAuthorityUnreconciled, principalID) + } + return view.ApprovedBuild(ctx, principalID) +} + +// RunOnce rebuilds the view from the inbox and installs it. +// +// AN UNREADABLE INBOX INSTALLS NOTHING. Which principals a row grants to is +// only knowable by reading it, so a row this host can no longer verify leaves +// the approved state for EVERY principal unestablished — and the one row whose +// disappearance grants something is a tombstone. Skipping it would resurrect +// the build it withdrew. So verification failure returns before anything is +// built and the previous view keeps serving. +// +// A principal the inbox describes INCOHERENTLY is a narrower failure, and it is +// handled per principal rather than per pass: see the comment at the install. +func (r *ApprovedBuildReconciler) RunOnce(ctx context.Context) error { + // A HOST THAT ANSWERS NO AUTHORITY QUESTION HAS NOTHING TO REFRESH, and + // that is a no-op rather than a failure. + // + // This returned the ceiling-unavailable error as a pass failure, and a test + // found it: every presence reconcile on a host with no envelope — or with no + // delivery inbox wired — started failing. That is precisely the coupling + // this design forbids. `newSolutionAuthorityActivation` says it outright: a + // host that reconciles presence with no ceiling delivered is a CORRECT, + // COMPLETE deployment, and making the presence half depend on the authority + // half being configured is the wrong direction. + // + // Nothing is hidden by returning early. The view stays empty, so every + // principal is UNKNOWN and every module mint is refused; and the operator + // learns by name at the point of use, where activation already refuses with + // ErrSolutionAuthorityCeilingUnavailable. The envelope is read once at boot + // and cannot change under a running process, so there is no case where a + // host had a ceiling and this skipped a refresh it owed. + if r.activation.envelope.Revision == 0 { + r.recordNothingToAnswer() + return nil + } + + documents, err := r.activation.liveAuthorityDocuments(ctx) + switch { + case errors.Is(err, ErrSolutionAuthorityCeilingUnavailable): + // No delivery inbox wired: the same "answers nothing" posture as no + // envelope, reached through the other half of the configuration. + r.recordNothingToAnswer() + return nil + case err != nil: + // An inbox that EXISTS and could not be read is a real failure, and the + // distinction is the whole point: a row this host can no longer verify + // leaves the approved state for every principal unestablished, and the + // one row whose disappearance grants something is a tombstone. + return fmt.Errorf("%w: %w", ErrApprovedBuildInboxUnreadable, err) + } + + // THE CEILING, before anything is approved. An authority document is + // signed, which makes it authentic and says nothing about whether what it + // claims is inside the ceiling a platform administrator wrote. Core keeps + // those as separate steps and says so — "whether it is inside a ceiling is + // ValidateAgainst" — and the clause that matters here is the last one: the + // build it is approved for must be one THE ENVELOPE approved. Without this + // call a delivery writer could approve any image at all by signing a + // document that names it, which is the entire check this view feeds. + // + inside := make([]*solutionhost.AuthorityDocument, 0, len(documents)) + var outside []error + for _, document := range documents { + if err := document.ValidateAgainst(r.activation.envelope); err != nil { + // Per document, not per pass: a document outside the ceiling is one + // delivery writer's problem, and the principals it names simply get + // no entry. Refusing the whole inbox over it would let any + // out-of-ceiling delivery deny every module on the host. + outside = append(outside, fmt.Errorf("authority %q generation %d is outside this host's ceiling: %w", + document.Authority, document.Generation, err)) + continue + } + inside = append(inside, document) + } + documents = inside + + // Which principals more than one live document grants to. Collected over + // the whole inbox before anything is approved, because the second document + // naming a principal is what makes the FIRST one ambiguous too — resolving + // as the rows arrive would approve from whichever happened to be read + // first. + grantedBy := map[string][]string{} + for _, document := range documents { + for _, principal := range document.Principals { + grantedBy[principal.Principal] = append(grantedBy[principal.Principal], document.Authority) + } + } + + next := NewMonotonicApprovedBuilds() + r.mutex.RLock() + // len(r.marks) INSIDE the lock. Sizing the map before taking it reads the + // map header while another pass may be assigning r.marks, which -race + // reports; RunOnce is exported and a test calling it twice concurrently + // would flake with the cause in neither test. + marks := make(map[string]uint64, len(r.marks)) + for principal, mark := range r.marks { + marks[principal] = mark + } + r.mutex.RUnlock() + + failures := outside + for _, document := range documents { + for _, principal := range document.Principals { + id := principal.Principal + if authorities := grantedBy[id]; len(authorities) > 1 { + // A COPY. Sorting in place mutates the slice the map still + // holds, during the loop that reads that map, purely to make a + // message deterministic — harmless only until the map is read + // again. + named := append([]string(nil), authorities...) + sort.Strings(named) + failures = append(failures, fmt.Errorf("%w: principal %q is granted by authorities %v", + ErrApprovedBuildAmbiguous, id, named)) + continue + } + if mark, had := marks[id]; had && document.Generation < mark { + // The rewind a fresh instance cannot see. Reported rather + // than clamped: a clamp would serve a value delivery did not + // intend and delivery would never learn. + failures = append(failures, fmt.Errorf( + "%w: authority %q delivers generation %d for principal %q, below the %d already served", + ErrApprovedBuildRewind, document.Authority, document.Generation, id, mark)) + continue + } + if err := next.Approve(id, ApprovedBuildRecord{ + Digest: ApprovedDigest(document.ApprovedBuild), + Incarnation: document.Generation, + }); err != nil { + failures = append(failures, fmt.Errorf("approve %q from authority %q generation %d: %w", + id, document.Authority, document.Generation, err)) + continue + } + marks[id] = document.Generation + } + } + + // A REFUSED PRINCIPAL IS OMITTED; THE REST INSTALL. The first version of + // this installed nothing when any principal was refused, on the argument + // that installing the coherent part of an incoherent inbox is how a + // withheld document becomes a quietly narrower system. A test found that + // wrong, and the reason is the inbox's shape: it is HOST-WIDE, so an + // authority document delivered for one solution sits in the same inbox as + // every other's. Withholding the whole pass therefore makes one solution's + // bad delivery deny service to every module on the host — a host-wide + // outage manufactured out of one unrelated document. + // + // Omitting the principal is no less fail-closed for the principal that is + // actually in doubt: it has no entry in the new view, so it reads back as + // UNKNOWN and every mint for it is refused. What it does not do is couple + // that refusal to principals whose documents say exactly one thing. The + // reasons are still returned, so an operator sees each refusal rather than + // inferring it from a principal that stopped working. + // + // The one failure that IS whole-pass is an unreadable inbox, and it is + // handled above by returning before any of this: there, nothing was + // established about any principal, so the previous view keeps serving. + r.mutex.Lock() + r.view = next + r.marks = marks + r.outcome = approvedBuildEstablished + r.mutex.Unlock() + return errors.Join(failures...) +} + +// recordNothingToAnswer notes that a pass ran against a host with no ceiling +// delivered, for reporting only. +// +// It takes no argument on purpose. The first draft took an outcome and guarded +// `!= approvedBuildNoPassYet` so a pass could never move the host BACK to +// reporting that nothing had run — and that guard was unreachable, because the +// only two callers pass approvedBuildNothingToAnswer. Mutation-verified: +// removing the guard changed no test, which is the signature of a branch no +// test can establish. It is deleted rather than kept with an untestable claim +// beside it, for the same reason the unbound-mint branch was. Monotonicity is +// now structural: approvedBuildNoPassYet is written at construction and by +// nothing else, so there is no path that regresses to it. +func (r *ApprovedBuildReconciler) recordNothingToAnswer() { + r.mutex.Lock() + defer r.mutex.Unlock() + r.outcome = approvedBuildNothingToAnswer +} + +var _ ExecutionAuthority = (*ApprovedBuildReconciler)(nil) diff --git a/module/services/accounts/code/pkg/business/module_approved_build_test.go b/module/services/accounts/code/pkg/business/module_approved_build_test.go new file mode 100644 index 000000000..71854924d --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_approved_build_test.go @@ -0,0 +1,191 @@ +//go:build pure + +package business + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// The monotonicity contract, including the clause core's own MemorySealSource +// missed. + +func digestA() ApprovedDigest { return ApprovedDigest(approvedBare) } +func digestB() ApprovedDigest { + return ApprovedDigest("sha256:" + + "3333333333333333333333333333333333333333333333333333333333333333") +} + +// Clause 1: the incarnation never decreases. +func TestApprovedBuildIncarnationNeverDecreases(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) + + err := builds.Approve("p", ApprovedBuildRecord{Digest: digestB(), Incarnation: 4}) + require.ErrorIs(t, err, ErrApprovedBuildRewind) + + // The refusal did not corrupt what is served. + digest, incarnation, err := builds.ApprovedBuild(context.Background(), "p") + require.NoError(t, err) + require.Equal(t, digestA(), digest) + require.Equal(t, uint64(5), incarnation) +} + +// CLAUSE 2, and the case that matters: a swap-back at a fixed counter. +// +// Approve B at 5, then A again at 5. Clause 1 alone permits it — the incarnation +// did not decrease — and every capability sealed to A at 5, which the move to B +// revoked, verifies again. This is the rewind reached through the field the rule +// did not cover, and the reason the rule has two clauses. +func TestApprovedBuildSwapBackAtAFixedIncarnationIsRefused(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestB(), Incarnation: 6})) + + // Back to A, at B's incarnation. Not a decrease, and still a rewind. + err := builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 6}) + require.ErrorIs(t, err, ErrApprovedBuildRewind) + require.Contains(t, err.Error(), "without advancing incarnation") + + // Back to A WITH an advance is fine: that is a real new approval. + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 7})) + digest, incarnation, err := builds.ApprovedBuild(context.Background(), "p") + require.NoError(t, err) + require.Equal(t, digestA(), digest) + require.Equal(t, uint64(7), incarnation) +} + +// Re-approving the SAME digest at the same incarnation is idempotent, not a +// rewind — a re-delivery of an unchanged document must not be refused. +func TestReApprovingTheSameBuildIsIdempotent(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 5})) +} + +// The same content written as a bare digest and as a full reference is ONE +// digest, so a respelled re-delivery is not a change demanding a bump. +func TestTheSameDigestSpelledTwoWaysIsNotAChange(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: ApprovedDigest(approvedBare), Incarnation: 5})) + require.NoError(t, builds.Approve("p", ApprovedBuildRecord{Digest: ApprovedDigest(approvedRef), Incarnation: 5}), + "a respelling of the same content is not a move to a different image") +} + +// The three states are distinct, and the distinction is the safety property. +// +// Collapsing "unknown" into "bears none" would mint an unbound capability for an +// identity the host has never heard of. +func TestApprovedBuildHasThreeDistinctStates(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + + _, _, err := builds.ApprovedBuild(context.Background(), "stranger") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) + require.NotErrorIs(t, err, ErrNoApprovedBuild) + + builds.Declare("declared") + _, _, err = builds.ApprovedBuild(context.Background(), "declared") + require.ErrorIs(t, err, ErrNoApprovedBuild) + require.NotErrorIs(t, err, ErrUnknownExecutionPrincipal) + + require.NoError(t, builds.Approve("approved", ApprovedBuildRecord{Digest: digestA(), Incarnation: 2})) + digest, incarnation, err := builds.ApprovedBuild(context.Background(), "approved") + require.NoError(t, err) + require.Equal(t, digestA(), digest) + require.Equal(t, uint64(2), incarnation) +} + +// A COLD START says "no pass has completed", not "I have never heard of this +// principal". +// +// Both refuse, so nothing is admitted either way and this is purely about what +// the operator is told. It matters because the two answers prompt opposite +// actions: "unknown principal" sends them to change a declaration that is +// already correct, while the host was simply still starting. The reconciler +// installs an empty view at construction, and an empty view answers +// ErrUnknownExecutionPrincipal for everyone — so every mint between process +// start and the first pass accused the declaration. +// +// The third state is the one that must NOT be reported this way: a host with no +// ceiling delivered answers no authority question at all, is a correct and +// complete deployment, and its principals genuinely are unknown forever. +// +// Mutation-verified, and one claim I had to withdraw: deleting the +// approvedBuildNoPassYet branch from ApprovedBuild fails the first assertion +// (it reverts to ErrUnknownExecutionPrincipal), and dropping the +// recordNothingToAnswer call from the zero-envelope early return fails the +// second (a host that answers nothing then reports "unreconciled" forever, +// which is the inverse error). The third assertion, that the state never +// regresses, is NOT verified by a mutation: I first wrote a guard in the setter +// and claimed this caught it, then found removing that guard changed no test, +// because both callers pass the same value. The guard was deleted as +// unreachable and monotonicity is structural instead — approvedBuildNoPassYet +// is written at construction and nowhere else. The assertion stays as a +// regression tripwire for a future third caller, and it is honest that nothing +// currently exercises it. +func TestAColdStartIsNotAnUnknownPrincipal(t *testing.T) { + // A zero-revision envelope: no ceiling delivered. Accepted at construction + // deliberately — see newSolutionAuthorityActivation. + reconciler, err := NewApprovedBuildReconciler(&SolutionAuthorityActivation{}) + require.NoError(t, err) + + _, _, err = reconciler.ApprovedBuild(context.Background(), "declared-principal") + require.ErrorIs(t, err, ErrExecutionAuthorityUnreconciled, + "before any pass the host has established nothing, which is not a statement about the principal") + require.NotErrorIs(t, err, ErrUnknownExecutionPrincipal, + "reporting a cold start as an unknown principal sends the operator to a declaration that is correct") + + // A pass runs. This host answers no authority question, so it establishes + // nothing — and from here "unknown" is the ACCURATE answer, because no + // ceiling will ever name this principal. + require.NoError(t, reconciler.RunOnce(context.Background())) + _, _, err = reconciler.ApprovedBuild(context.Background(), "declared-principal") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal, + "a host with no ceiling delivered is complete, and its principals are unknown rather than pending") + require.NotErrorIs(t, err, ErrExecutionAuthorityUnreconciled, + "it must not report 'no pass has completed' forever: a pass did complete and established nothing") + + // And it never regresses: a second pass cannot take the host back to + // reporting that nothing has run. + require.NoError(t, reconciler.RunOnce(context.Background())) + _, _, err = reconciler.ApprovedBuild(context.Background(), "declared-principal") + require.NotErrorIs(t, err, ErrExecutionAuthorityUnreconciled) +} + +// An empty authority means every principal is UNKNOWN, not that every principal +// bears no build — so a host that resolved nothing refuses rather than minting +// unbound capabilities for everyone. +func TestAnEmptyAuthorityRefusesRatherThanMintingUnbound(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + _, _, err := builds.ApprovedBuild(context.Background(), "anyone") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) +} + +// A non-digest cannot be approved: an approval is a statement about immutable +// content, and storing a tag now would serve a tag to every later check. +func TestANonDigestCannotBeApproved(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + for _, value := range []string{"registry.example.com/acme/worker:v1", "", "latest", "sha256:short"} { + err := builds.Approve("p", ApprovedBuildRecord{Digest: ApprovedDigest(value), Incarnation: 1}) + require.ErrorIs(t, err, ErrApprovedBuildRewind, "%q must not be approvable", value) + } +} + +// Incarnation 0 is refused here rather than at mint: core's schema requires +// gte=1 when the pair is present, so a zero would produce a seal its own +// validation rejects, far from where the value was written. +func TestIncarnationZeroIsRefused(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + err := builds.Approve("p", ApprovedBuildRecord{Digest: digestA(), Incarnation: 0}) + require.ErrorIs(t, err, ErrApprovedBuildRewind) +} + +// Principals are independent: one's incarnation says nothing about another's. +func TestPrincipalsAdvanceIndependently(t *testing.T) { + builds := NewMonotonicApprovedBuilds() + require.NoError(t, builds.Approve("a", ApprovedBuildRecord{Digest: digestA(), Incarnation: 9})) + require.NoError(t, builds.Approve("b", ApprovedBuildRecord{Digest: digestB(), Incarnation: 1}), + "a low incarnation for a different principal is not a rewind") +} diff --git a/module/services/accounts/code/pkg/business/module_audit_declarations.go b/module/services/accounts/code/pkg/business/module_audit_declarations.go index 31336f3e5..6828da820 100644 --- a/module/services/accounts/code/pkg/business/module_audit_declarations.go +++ b/module/services/accounts/code/pkg/business/module_audit_declarations.go @@ -28,7 +28,7 @@ import ( // a module may declare on every start. An admission that changed the registry // is audited with the write that made it. func (s *Service) ModuleDeclareAuditEventTypes(ctx context.Context, caller ModuleCaller, prefix string, declarations []AuditEventTypeDeclaration) ([]EventType, []string, error) { - if _, err := s.moduleGrant(caller); err != nil { + if _, err := s.moduleCapability(ctx, caller); err != nil { return nil, nil, err } if !callerIsModulePrefix(caller, prefix) { diff --git a/module/services/accounts/code/pkg/business/module_audit_declarations_integration_test.go b/module/services/accounts/code/pkg/business/module_audit_declarations_integration_test.go index d00ccaf3e..952c8cc98 100644 --- a/module/services/accounts/code/pkg/business/module_audit_declarations_integration_test.go +++ b/module/services/accounts/code/pkg/business/module_audit_declarations_integration_test.go @@ -35,6 +35,7 @@ func moduleDeclaringService(t *testing.T, bound map[string][]string) *business.S registry[business.ModulePrincipalID(prefix)] = business.ModulePrincipalGrant{Prefix: prefix, Namespaces: namespaces} } backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, registry) return svc } diff --git a/module/services/accounts/code/pkg/business/module_audit_declarations_test.go b/module/services/accounts/code/pkg/business/module_audit_declarations_test.go index 1c6b5addd..344cea349 100644 --- a/module/services/accounts/code/pkg/business/module_audit_declarations_test.go +++ b/module/services/accounts/code/pkg/business/module_audit_declarations_test.go @@ -48,6 +48,8 @@ func newModuleDeclaringService(t *testing.T, store *declaredAuditStore, grants M if err != nil { t.Fatal(err) } + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(grants) audit := &recordingAudit{} svc.SetAuditEmitter(audit) diff --git a/module/services/accounts/code/pkg/business/module_authority_fake_test.go b/module/services/accounts/code/pkg/business/module_authority_fake_test.go new file mode 100644 index 000000000..c623c93a9 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_authority_fake_test.go @@ -0,0 +1,29 @@ +package business_test + +import ( + "context" + + "accounts/pkg/business" +) + +// currentModuleAuthority is a live-authority read reporting a CURRENT +// installation, wired through the real seam like this package's other fakes. +// +// Enforcement at use made every capability test in this package fail closed: +// a Service that declares module principals but cannot re-read live authority +// now refuses every capability rather than deciding on the declared ceiling +// alone. That is the enforcement working, so the tests wire the read rather +// than the Service growing a permissive default — a default would make the +// check optional in exactly the configuration tests run in, which is the one +// configuration where it must not be. +// +// Revision and epoch are 0 on purpose: a credential sealed at any non-zero +// value is still refused through this, so a test proving a stale seal is +// rejected needs no opt-out. +type currentModuleAuthority struct{} + +func (currentModuleAuthority) LiveModuleAuthority( + _ context.Context, _, _, _ string, +) (*business.LiveModuleAuthority, error) { + return &business.LiveModuleAuthority{InstallationID: "installation-current"}, nil +} diff --git a/module/services/accounts/code/pkg/business/module_authority_testsupport_test.go b/module/services/accounts/code/pkg/business/module_authority_testsupport_test.go new file mode 100644 index 000000000..2c3537e71 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_authority_testsupport_test.go @@ -0,0 +1,35 @@ +package business + +import "context" + +// currentModuleAuthority is the live read the EXISTING capability tests get. +// +// They exist to exercise delegation, oracles and exchange behaviour, and +// enforcement at use made every one of them fail closed — which is the +// enforcement working, not a test defect: a Service with a declared registry and +// no live read now refuses every capability rather than deciding on the declared +// ceiling alone. +// +// So they are given a read that reports a CURRENT installation. That keeps them +// testing what they are about while still going through +// AuthorizeModuleCapability, rather than bypassing it — the alternative, +// allowing an unwired Service to serve, would have made the whole check +// optional in exactly the configuration tests run in. +// +// It reports revision and epoch 0 deliberately: a credential sealed at any +// non-zero value is refused by it, so a test that wants to prove a stale seal is +// refused does not have to opt out of this. +type currentModuleAuthority struct{} + +func (currentModuleAuthority) LiveModuleAuthority( + _ context.Context, _, _, _ string, +) (*LiveModuleAuthority, error) { + return &LiveModuleAuthority{InstallationID: "installation-current"}, nil +} + +// withCurrentAuthority wires it. Named so a reader of a failing test can see at +// once that the live read is a fixture rather than the thing under test. +func withCurrentAuthority(service *Service) *Service { + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) + return service +} diff --git a/module/services/accounts/code/pkg/business/module_capabilities.go b/module/services/accounts/code/pkg/business/module_capabilities.go index 89cdfe442..2b2a4b8d7 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities.go +++ b/module/services/accounts/code/pkg/business/module_capabilities.go @@ -24,6 +24,7 @@ import ( "regexp" "slices" "sort" + "strings" "time" "accounts/pkg/datasource/github" @@ -52,6 +53,98 @@ import ( // worker needs to service every tenant's deliveries on its queue. Tenant is the // org a single-tenant module is bound to, which its minted Work Context carries; // a cross-tenant module names the tenant per mint instead. +// ModuleWorkload is the Kubernetes identity this principal's pods run as, and +// the container within them that runs its image. +// +// WHY THE DECLARATION HOLDS THIS AT ALL. Execution binding is unconditional, so +// `BindExecution` is the only thing authenticating a module mint — and it takes +// the principal as an INPUT. On its own that leaves the principal a bare claim: +// a module presenting its own perfectly valid service-account token could name +// ANOTHER module's prefix, and the check would then compare that other +// principal's approved build against this caller's running image. Two modules +// built from one image are interchangeable, and a shared base image makes the +// hole wider than that. The shared secret used to bind the prefix to the +// caller; removing it without this would have removed the binding. +// +// So the declaration says which workload a principal IS, the TokenReview says +// which workload the caller is, and a mismatch is a refusal. The prefix becomes +// a claim the host can check rather than one it has to believe. +// +// THE CONTAINER IS DECLARED, NOT CHOSEN. It was a caller-supplied argument, and +// that is the last degree of freedom the caller had over its own identity: a +// pod's containers do not all run the same image, so naming a sibling picks +// which image the approval is tested against. Reading it from the declaration +// means nothing about the identity is the caller's to pick — the prefix is +// checked, the pod comes from the token, the container comes from here, the +// running image comes from the pod's status, and the approval comes from a +// signed document. +type ModuleWorkload struct { + ServiceAccount string `json:"service_account"` + Namespace string `json:"namespace"` + Container string `json:"container"` +} + +// declared reports whether the workload is fully stated. All three or none: a +// partial workload would check the parts it has and silently skip the rest, +// which is the whole-or-absent trap. +func (w ModuleWorkload) declared() bool { + return w.ServiceAccount != "" && w.Namespace != "" && w.Container != "" +} + +// dns1123LabelPattern is Kubernetes' DNS-1123 label rule, which a service +// account, a namespace and a container name must each satisfy to name a real +// object. +// +// Deliberately NOT registrationIdentityPattern, which has the same shape today. +// That one mirrors the gateway's catalog-identity rule and is free to change +// with the router; this one is Kubernetes' and changes only when Kubernetes +// does. Sharing them would let a routing decision silently redefine what can +// name a pod. +var dns1123LabelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +const dns1123LabelMaxLength = 63 + +// validateForm refuses a workload whose three values are PRESENT but cannot +// name a real Kubernetes object. +// +// declared() catches absence; this catches malformation, and the difference +// matters because of where the second one used to surface. A trailing space or +// an uppercase letter parsed cleanly and the host booted; then every mint +// compared the declared value against the TokenReview's real one and returned +// ErrExecutionIdentityMismatch — "X is declared to run as A/B, the presented +// token authenticates C/D". That is a verdict that the CALLER is lying, so a +// one-character typo in a declaration sent the operator to audit the module. +// Caught here, they read one line naming the entry and the field. +// +// Trim-difference is REFUSED rather than trimmed. Silently trimming would make +// the value the host compares differ from the value the composition rendered, +// which is a drift nothing downstream can see — the same reason a canonicalized +// tenant is parsed rather than patched. +func (w ModuleWorkload) validateForm(prefix string) error { + for _, field := range []struct{ name, value string }{ + {"service_account", w.ServiceAccount}, + {"namespace", w.Namespace}, + {"container", w.Container}, + } { + if trimmed := strings.TrimSpace(field.value); trimmed != field.value { + return fmt.Errorf( + "module principal %q declares workload %s %q with leading or trailing whitespace: "+ + "it is refused rather than trimmed, because a trimmed value no longer equals what "+ + "the composition rendered and every mint would then be denied as an identity mismatch", + prefix, field.name, field.value) + } + if len(field.value) > dns1123LabelMaxLength || !dns1123LabelPattern.MatchString(field.value) { + return fmt.Errorf( + "module principal %q declares workload %s %q, which is not a DNS-1123 label "+ + "(lowercase alphanumerics and hyphens, starting and ending alphanumeric, "+ + "at most %d characters): it can never name a real Kubernetes object, so every "+ + "mint for this principal would be denied as an identity mismatch", + prefix, field.name, field.value, dns1123LabelMaxLength) + } + } + return nil +} + type ModulePrincipalGrant struct { ArtifactPolicies map[string]ExecutableArtifactPolicy `json:"artifact_policies"` ReadAudiences map[string]ModuleReadAudience `json:"read_audiences"` @@ -70,6 +163,11 @@ type ModulePrincipalGrant struct { Resources []string CrossTenant bool Tenant string + + // Workload is the Kubernetes identity this principal runs as. Required: + // see ModuleWorkload for why a principal without one cannot be + // authenticated at all. + Workload ModuleWorkload } func (g ModulePrincipalGrant) allowsQueue(queue string) bool { @@ -211,6 +309,7 @@ func ParseModulePrincipalRegistry(raw string) (ModulePrincipalRegistry, error) { Resources []string `json:"resources"` CrossTenant bool `json:"cross_tenant"` Tenant string `json:"tenant"` + Workload ModuleWorkload `json:"workload"` } if err := json.Unmarshal([]byte(raw), &wire); err != nil { return nil, err @@ -243,6 +342,22 @@ func ParseModulePrincipalRegistry(raw string) (ModulePrincipalRegistry, error) { if err := validateArtifactPolicies(grant.ArtifactPolicies); err != nil { return nil, fmt.Errorf("module %q: %w", prefix, err) } + // The workload, refused at boot rather than at the mint it would deny. + // A principal with no declared workload can never be authenticated — + // execution binding is unconditional and has nothing to compare the + // reviewed service account against — so an entry missing it is a + // composition that cannot work, and saying so here names the entry + // instead of leaving every one of its calls refused as "denied". + if !grant.Workload.declared() { + return nil, fmt.Errorf( + "module principal %q must declare its workload identity (service_account, namespace, container): "+ + "execution binding is unconditional, so without it the host cannot tell this principal's pods "+ + "from any other caller holding a valid token", + prefix) + } + if err := grant.Workload.validateForm(prefix); err != nil { + return nil, err + } if err := validateReadAudiences(prefix, grant.ReadAudiences); err != nil { return nil, err } @@ -281,6 +396,7 @@ func ParseModulePrincipalRegistry(raw string) (ModulePrincipalRegistry, error) { Resources: grant.Resources, CrossTenant: grant.CrossTenant, Tenant: tenant.String(), + Workload: grant.Workload, } } return registry, nil @@ -291,6 +407,16 @@ func ParseModulePrincipalRegistry(raw string) (ModulePrincipalRegistry, error) { type ModuleCaller struct { PrincipalID string // the acting service principal (subject) id BoundOrg string // the tenant the principal is bound to + + // Sealed is what the credential CLAIMED at mint, when it claims anything. + // + // Nil, and every field within it nil, means "this credential makes no claim + // about that term" — which is every credential until the Work Context + // cutover. Pointers rather than values precisely so absence and zero stay + // distinguishable: a zero installation revision is a real revision, so + // defaulting absence to zero would make the check PASS for exactly the + // credentials that claim nothing. + Sealed *SealedModuleAuthority } // grant resolves the caller's declared authority. An unknown principal is @@ -299,13 +425,33 @@ func (s *Service) moduleGrant(caller ModuleCaller) (ModulePrincipalGrant, error) if caller.PrincipalID == "" { return ModulePrincipalGrant{}, status.Error(codes.Unauthenticated, "module caller identity required") } - grant, ok := s.modulePrincipals[caller.PrincipalID] + grant, ok := s.declaredModules()[caller.PrincipalID] if !ok { return ModulePrincipalGrant{}, status.Errorf(codes.PermissionDenied, "principal %s is not a registered module principal", caller.PrincipalID) } return grant, nil } +// moduleCapability is moduleGrant WITH the live re-read, and it deliberately has +// moduleGrant's exact shape. +// +// Same returns, one extra parameter. That makes routing a capability path a +// one-token edit rather than a restructuring of its error handling — and the +// first attempt at this change did restructure 25 call sites with a script, +// which broke return arities and duplicated error returns in four files. A +// mechanical migration is only safe when it is actually mechanical. +// +// A path needing the live installation id or producer epoch — a deferred +// producer stamping work, say — calls AuthorizeModuleCapability directly and +// keeps the whole authority. +func (s *Service) moduleCapability(ctx context.Context, caller ModuleCaller) (ModulePrincipalGrant, error) { + authority, err := s.AuthorizeModuleCapability(ctx, caller) + if err != nil { + return ModulePrincipalGrant{}, err + } + return authority.Grant, nil +} + // ModuleContentResources reports the permission resource types the content of // one composed module is governed by. prefix is the module's registration // prefix — the name a composition declares its principal under, and the audience @@ -321,7 +467,7 @@ func (s *Service) moduleGrant(caller ModuleCaller) (ModulePrincipalGrant, error) // This is the per-caller counterpart of ContentResources, which takes the union // because its caller is an org administrator rather than one module. func (s *Service) ModuleContentResources(prefix string) ([]string, error) { - grant, registered := s.modulePrincipals[ModulePrincipalID(prefix)] + grant, registered := s.declaredModules()[ModulePrincipalID(prefix)] if !registered || len(grant.Resources) == 0 { return nil, status.Error(codes.PermissionDenied, "capability audience declares no module content") } @@ -375,7 +521,7 @@ func (s *Service) requireTenantMember(ctx context.Context, tenant, userID string // worker producer. func (s *Service) ModuleEnqueueJob(ctx context.Context, caller ModuleCaller, tenant string, req *jobsv1.EnqueueJobRequest) (*jobsv1.EnqueueJobResponse, error) { w := wool.Get(ctx).In("ModuleEnqueueJob") - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return nil, err } @@ -450,7 +596,7 @@ func (s *Service) ModuleEnqueueJob(ctx context.Context, caller ModuleCaller, ten // queue (e.g. datasource, which carries every tenant's ingest jobs). func (s *Service) ModuleClaimJobs(ctx context.Context, caller ModuleCaller, req *jobsv1.ClaimJobsRequest) (*jobsv1.ClaimJobsResponse, error) { w := wool.Get(ctx).In("ModuleClaimJobs") - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return nil, err } @@ -471,7 +617,7 @@ func (s *Service) ModuleClaimJobs(ctx context.Context, caller ModuleCaller, req // caller without the current, unexpired token cannot renew. func (s *Service) ModuleHeartbeatJob(ctx context.Context, caller ModuleCaller, req *jobsv1.HeartbeatJobRequest) (*jobsv1.HeartbeatJobResponse, error) { w := wool.Get(ctx).In("ModuleHeartbeatJob") - if _, err := s.moduleGrant(caller); err != nil { + if _, err := s.moduleCapability(ctx, caller); err != nil { return nil, err } resp, err := s.moduleJobStore.Heartbeat(ctx, req) @@ -484,7 +630,7 @@ func (s *Service) ModuleHeartbeatJob(ctx context.Context, caller ModuleCaller, r // ModuleAckJob completes a leased job successfully. func (s *Service) ModuleAckJob(ctx context.Context, caller ModuleCaller, lease *jobsv1.JobLeaseReference, executionKind, executionID string) error { w := wool.Get(ctx).In("ModuleAckJob") - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return err } @@ -506,7 +652,7 @@ func (s *Service) ModuleAckJob(ctx context.Context, caller ModuleCaller, lease * // the attempt budget is exhausted); otherwise it dead-letters immediately. func (s *Service) ModuleNackJob(ctx context.Context, caller ModuleCaller, lease *jobsv1.JobLeaseReference, failure *jobsv1.JobFailure, retryable bool, retryAt *timestamppb.Timestamp) error { w := wool.Get(ctx).In("ModuleNackJob") - if _, err := s.moduleGrant(caller); err != nil { + if _, err := s.moduleCapability(ctx, caller); err != nil { return err } if retryable { @@ -571,7 +717,7 @@ type ModuleNotifyUserResult struct { // notifications are user-scoped, so the tenant guard alone does not stop a // module bound to tenant A from notifying a user in tenant B. func (s *Service) ModuleNotifyUser(ctx context.Context, caller ModuleCaller, in ModuleNotifyUserInput) (ModuleNotifyUserResult, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return ModuleNotifyUserResult{}, err } @@ -637,7 +783,7 @@ type ModuleNotifyOrgAdminsInput struct { // key and the recipient, so a redelivered call converges on the rows it already // wrote. The send is audited with the recipient and delivery counts. func (s *Service) ModuleNotifyOrgAdmins(ctx context.Context, caller ModuleCaller, in ModuleNotifyOrgAdminsInput) (bool, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return false, err } @@ -745,7 +891,7 @@ type ModuleRequestApprovalInput struct { // resume queue must be one the principal may claim, so a decision cannot direct // work onto a queue the module does not own. func (s *Service) ModuleRequestApproval(ctx context.Context, caller ModuleCaller, in ModuleRequestApprovalInput) (string, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return "", err } @@ -775,7 +921,7 @@ func (s *Service) ModuleRequestApproval(ctx context.Context, caller ModuleCaller // ModuleGetApproval returns one approval request on the caller's tenant. func (s *Service) ModuleGetApproval(ctx context.Context, caller ModuleCaller, tenant, id string) (*ApprovalRequest, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return nil, err } @@ -791,7 +937,7 @@ func (s *Service) ModuleGetApproval(ctx context.Context, caller ModuleCaller, te // ModuleCancelApproval withdraws a still-open approval request. func (s *Service) ModuleCancelApproval(ctx context.Context, caller ModuleCaller, tenant, id, reason string) error { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return err } @@ -904,7 +1050,7 @@ func (s *Service) enqueueApprovalResume(ctx context.Context, req *ApprovalReques // entryID must be an opaque identifier; one that is a locator is dropped rather // than stored, for the reasons opaqueAuditEntryID gives. func (s *Service) ModuleEmitAuditEvent(ctx context.Context, caller ModuleCaller, tenant, eventType, actor, solution, entryID, idempotencyKey string, fields *structpb.Struct) error { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return err } @@ -1191,7 +1337,7 @@ type ModuleSubjectVisibilityGrant struct { // bound to one tenant could name a subject in another and learn whether that // subject has colleagues. func (s *Service) ModuleListSubjectVisibility(ctx context.Context, caller ModuleCaller, tenant, viewer string) ([]ModuleSubjectVisibilityGrant, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return nil, err } @@ -1239,7 +1385,7 @@ func (s *Service) ModuleListSubjectVisibility(ctx context.Context, caller Module // grant names, so it can neither introduce a type it holds no grant for nor // re-point a record another module owns. func (s *Service) ModulePlaceRecord(ctx context.Context, caller ModuleCaller, tenant, scopePath, kind, label, resourceType, resourceID string) (string, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return "", err } @@ -1306,7 +1452,7 @@ func (s *Service) ModulePlaceRecord(ctx context.Context, caller ModuleCaller, te // anything over maxContentTicketBytes is refused rather than buffered. func (s *Service) ModuleFetchDatasourceBlob(ctx context.Context, caller ModuleCaller, sourceID, blobSHA string) ([]byte, string, error) { w := wool.Get(ctx).In("ModuleFetchDatasourceBlob") - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return nil, "", err } @@ -1400,7 +1546,7 @@ func mapPublishError(err error) error { // of record is itself the trail — so only subscription changes and replays emit // audit events. func (s *Service) ModulePublishEvent(ctx context.Context, caller ModuleCaller, tenant string, envelope *events.EventEnvelope) (string, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return "", err } @@ -1499,7 +1645,7 @@ func missingFollowableSubject(envelope *events.EventEnvelope) (string, bool) { // ones taken against the module itself. A re-subscribe of the same (principal, // pattern, queue) returns the existing live row and emits no second audit event. func (s *Service) ModuleSubscribe(ctx context.Context, caller ModuleCaller, typePattern, queue, delivery string) (*EventSubscription, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return nil, err } @@ -1564,7 +1710,7 @@ func (s *Service) ModuleSubscribe(ctx context.Context, caller ModuleCaller, type // revoking an unknown or already-revoked subscription is NotFound and emits no // audit event. func (s *Service) ModuleUnsubscribe(ctx context.Context, caller ModuleCaller, subscriptionID string) error { - if _, err := s.moduleGrant(caller); err != nil { + if _, err := s.moduleCapability(ctx, caller); err != nil { return err } var revoked bool @@ -1591,7 +1737,7 @@ func (s *Service) ModuleUnsubscribe(ctx context.Context, caller ModuleCaller, su // ModuleListSubscriptions returns the calling principal's live subscriptions. func (s *Service) ModuleListSubscriptions(ctx context.Context, caller ModuleCaller) ([]*EventSubscription, error) { - if _, err := s.moduleGrant(caller); err != nil { + if _, err := s.moduleCapability(ctx, caller); err != nil { return nil, err } var subscriptions []*EventSubscription @@ -1611,7 +1757,7 @@ func (s *Service) ModuleListSubscriptions(ctx context.Context, caller ModuleCall // acknowledged the event still receives the replay. The replay itself is // audited (a control-plane action) but the individual redeliveries are not. func (s *Service) ModuleReplayEvents(ctx context.Context, caller ModuleCaller, tenant, eventType string, since time.Time) (int, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return 0, err } diff --git a/module/services/accounts/code/pkg/business/module_capabilities_declared_audit_test.go b/module/services/accounts/code/pkg/business/module_capabilities_declared_audit_test.go index b08e642ce..33c2bb164 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities_declared_audit_test.go +++ b/module/services/accounts/code/pkg/business/module_capabilities_declared_audit_test.go @@ -52,6 +52,7 @@ func newDeclaredEmitService(t *testing.T, namespaces ...string) (*business.Servi emitter := &capturingAuditEmitter{} svc.SetAuditEmitter(emitter) backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Prefix: "content", Namespaces: namespaces}, }) diff --git a/module/services/accounts/code/pkg/business/module_capabilities_follows_internal_test.go b/module/services/accounts/code/pkg/business/module_capabilities_follows_internal_test.go index 576901c81..c620c800c 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities_follows_internal_test.go +++ b/module/services/accounts/code/pkg/business/module_capabilities_follows_internal_test.go @@ -71,6 +71,8 @@ func newFollowService(t *testing.T) *Service { if err != nil { t.Fatalf("NewService: %v", err) } + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(ModulePrincipalRegistry{ followPrincipal: {Namespaces: []string{"scope"}}, }) diff --git a/module/services/accounts/code/pkg/business/module_capabilities_idempotency_test.go b/module/services/accounts/code/pkg/business/module_capabilities_idempotency_test.go index 4d3494b7e..f807abdc8 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities_idempotency_test.go +++ b/module/services/accounts/code/pkg/business/module_capabilities_idempotency_test.go @@ -39,6 +39,7 @@ func TestModuleEmitAuditEventDedupsRetriedTenantEmit(t *testing.T) { require.NoError(t, err) svc.SetAuditEmitter(emitter) backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: []string{"datasource", "documents"}}, }) @@ -91,6 +92,7 @@ func TestModuleEmitAuditEventRowKeepsItsActorAndEntry(t *testing.T) { require.NoError(t, err) svc.SetAuditEmitter(emitter) backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: []string{"documents"}}, }) diff --git a/module/services/accounts/code/pkg/business/module_capabilities_test.go b/module/services/accounts/code/pkg/business/module_capabilities_test.go index 8bd9a8442..f714d5d92 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities_test.go +++ b/module/services/accounts/code/pkg/business/module_capabilities_test.go @@ -129,6 +129,7 @@ func newModuleService(t *testing.T, backend *fakeJobBackend) *business.Service { if err != nil { t.Fatalf("NewService: %v", err) } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Prefix: "content", Queues: []string{"datasource", "documents"}}, }) @@ -141,6 +142,7 @@ func newModuleServiceWithStore(t *testing.T, store business.Store, backend *fake if err != nil { t.Fatalf("NewService: %v", err) } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Prefix: "content", Queues: []string{"datasource", "documents"}, CrossTenant: crossTenant}, }) @@ -870,6 +872,7 @@ func TestApprovalResumeEnqueuedOnQuorum(t *testing.T) { t.Fatalf("NewService: %v", err) } backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: []string{"documents"}}, }) @@ -923,6 +926,7 @@ func TestApprovalResumeStampsCompletingDecider(t *testing.T) { t.Fatalf("NewService: %v", err) } backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: []string{"documents"}}, }) @@ -1006,7 +1010,7 @@ func TestModuleRequestApproval_ResumeQueueMustBeAllowed(t *testing.T) { // an opaque id and every side computes the same one. func TestParseModulePrincipalRegistry_IndexesByDerivedPrincipal(t *testing.T) { registry, err := business.ParseModulePrincipalRegistry( - `{"documents":{"queues":["datasource"],"namespaces":["document"],"tenant":"` + moduleTenantA + `"}}`) + `{"documents":{"queues":["datasource"],"namespaces":["document"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleTenantA + `"}}`) if err != nil { t.Fatalf("parse: %v", err) } @@ -1026,7 +1030,7 @@ func TestParseModulePrincipalRegistry_IndexesByDerivedPrincipal(t *testing.T) { // two the host reads out of a sealed capability are refused. func TestParseModulePrincipalRegistry_AcceptsOrdinaryContentResources(t *testing.T) { registry, err := business.ParseModulePrincipalRegistry( - `{"documents":{"resources":["documents.files","rolesets"],"tenant":"` + moduleTenantA + `"}}`) + `{"documents":{"resources":["documents.files","rolesets"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleTenantA + `"}}`) if err != nil { t.Fatalf("parse: %v", err) } @@ -1045,16 +1049,16 @@ func TestParseModulePrincipalRegistry_AcceptsOrdinaryContentResources(t *testing func TestParseModulePrincipalRegistry_RejectsUnusableDeclarations(t *testing.T) { tests := map[string]string{ - "invalid prefix": `{"Documents/v1":{"queues":["datasource"],"tenant":"` + moduleTenantA + `"}}`, + "invalid prefix": `{"Documents/v1":{"queues":["datasource"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleTenantA + `"}}`, // The tenant is sealed into a signed capability and compared against // organization ids: a malformed one signs, then matches no tenant and drops // the org from its own audit record, so it is rejected where it is read. "no tenant": `{"documents":{"queues":["datasource"]}}`, - "non-uuid tenant": `{"documents":{"queues":["datasource"],"tenant":"acme-org"}}`, + "non-uuid tenant": `{"documents":{"queues":["datasource"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"acme-org"}}`, // A principal id is a valid prefix by pattern, so an entry keyed the way the // registry used to be would otherwise parse into a principal no module can // ever be, denying every call for a reason that names the caller. - "keyed by principal id": `{"` + modulePrincSvc + `":{"queues":["datasource"],"cross_tenant":true,"tenant":"` + moduleTenantA + `"}}`, + "keyed by principal id": `{"` + modulePrincSvc + `":{"queues":["datasource"],"cross_tenant":true,"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleTenantA + `"}}`, // `roles` and `audit` are read off a sealed capability by the host itself // (the collection-metadata disclosure) rather than authorized per node, so // the content-read branch's "every read is re-authorized per node" @@ -1063,8 +1067,8 @@ func TestParseModulePrincipalRegistry_RejectsUnusableDeclarations(t *testing.T) // who holds a grant on every readable collection, which only an // organization-wide assignment could reach before. The registry is operator // text this host cannot otherwise check, so it is refused at parse time. - "host-sealed scope resource roles": `{"documents":{"resources":["documents.files","roles"],"tenant":"` + moduleTenantA + `"}}`, - "host-sealed scope resource audit": `{"documents":{"resources":["audit"],"tenant":"` + moduleTenantA + `"}}`, + "host-sealed scope resource roles": `{"documents":{"resources":["documents.files","roles"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleTenantA + `"}}`, + "host-sealed scope resource audit": `{"documents":{"resources":["audit"],"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + moduleTenantA + `"}}`, } for name, raw := range tests { t.Run(name, func(t *testing.T) { @@ -1110,6 +1114,7 @@ func newVisibilityService(t *testing.T, store business.Store, crossTenant bool) if err != nil { t.Fatalf("NewService: %v", err) } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ modulePrincSvc: {Prefix: "content", CrossTenant: crossTenant}, }) @@ -1523,3 +1528,101 @@ func TestModuleNotifyOrgAdmins_RefusedBeforeAnyWrite(t *testing.T) { t.Fatalf("a refused call wrote %d notifications", len(store.notified)) } } + +// A declaration with no workload FAILS THE BOOT, naming the entry. +// +// Config errors fail early here, and this is one: execution binding is +// unconditional, so a principal whose workload is not declared can never be +// authenticated — every call it makes is refused. Caught at parse, the operator +// reads one line naming the entry; caught at the mint, they read "denied" and go +// looking at the caller. +// +// All three parts, each on its own, because a partial workload is the +// whole-or-absent trap: it would check the parts it has and silently skip the +// rest, which reads as enforcement. +func TestAModulePrincipalMustDeclareItsWorkload(t *testing.T) { + const tenant = "019f6bf7-5b4b-74e5-8c17-092259bb1661" + for name, workload := range map[string]string{ + "absent": ``, + "empty": `"workload":{},`, + "no service account": `"workload":{"namespace":"acme-prod","container":"app"},`, + "no namespace": `"workload":{"service_account":"documents","container":"app"},`, + "no container": `"workload":{"service_account":"documents","namespace":"acme-prod"},`, + } { + t.Run(name, func(t *testing.T) { + _, err := business.ParseModulePrincipalRegistry( + `{"documents":{` + workload + `"tenant":"` + tenant + `"}}`) + if err == nil { + t.Fatal("a principal that can never be authenticated must not parse") + } + if !strings.Contains(err.Error(), "documents") || !strings.Contains(err.Error(), "workload") { + t.Fatalf("the refusal must name the entry and the workload, or an operator cannot find it: %v", err) + } + }) + } + + // NON-VACUITY: a fully declared workload parses, so the cases above fail for + // the workload rather than for something else in the fixture. + registry, err := business.ParseModulePrincipalRegistry( + `{"documents":{"workload":{"service_account":"documents","namespace":"acme-prod","container":"app"},` + + `"tenant":"` + tenant + `"}}`) + if err != nil { + t.Fatalf("a fully declared entry must parse: %v", err) + } + want := business.ModuleWorkload{ServiceAccount: "documents", Namespace: "acme-prod", Container: "app"} + if got := registry[business.ModulePrincipalID("documents")].Workload; got != want { + t.Fatalf("the parsed workload must be carried through verbatim, or it reads as undeclared later: got %+v want %+v", got, want) + } +} + +// A workload whose three values are PRESENT but malformed is refused at parse, +// naming the entry and the field. +// +// The test above covers absence. This covers the case that used to boot +// cleanly: a value that cannot name a Kubernetes object parsed, and the failure +// surfaced at the mint as ErrExecutionIdentityMismatch — "declared to run as +// A/B, the presented token authenticates C/D" — which accuses the CALLER. A +// trailing space in a declaration was reported as an impersonation attempt, so +// the operator audited the module instead of reading the one line that was +// wrong. +// +// Mutation-verified: removing the grant.Workload.validateForm(prefix) call from +// the parse guard makes every row below fail (each fixture parses and the test +// demands a refusal), and dropping the field-name assertion alone still leaves +// the rows failing — so the rows test the refusal and the extra assertion tests +// that the refusal is findable. Replacing the DNS-1123 check with a bare +// non-emptiness test fails every row except the two whitespace ones, which is +// what distinguishes this check from declared(). +func TestAMalformedModuleWorkloadIsRefusedAtParseNotAtTheMint(t *testing.T) { + const tenant = "019f6bf7-5b4b-74e5-8c17-092259bb1661" + for name, fixture := range map[string]struct{ workload, field string }{ + "trailing space in service account": { + `"workload":{"service_account":"documents ","namespace":"acme-prod","container":"app"},`, "service_account"}, + "leading space in namespace": { + `"workload":{"service_account":"documents","namespace":" acme-prod","container":"app"},`, "namespace"}, + "uppercase namespace is not a DNS-1123 label": { + `"workload":{"service_account":"documents","namespace":"Acme-Prod","container":"app"},`, "namespace"}, + "underscore is not a DNS-1123 label": { + `"workload":{"service_account":"documents_api","namespace":"acme-prod","container":"app"},`, "service_account"}, + "leading hyphen is not a DNS-1123 label": { + `"workload":{"service_account":"documents","namespace":"-acme-prod","container":"app"},`, "namespace"}, + "uppercase container": { + `"workload":{"service_account":"documents","namespace":"acme-prod","container":"App"},`, "container"}, + "label longer than 63 characters": { + `"workload":{"service_account":"documents","namespace":"acme-prod","container":"` + + strings.Repeat("a", 64) + `"},`, "container"}, + } { + t.Run(name, func(t *testing.T) { + _, err := business.ParseModulePrincipalRegistry( + `{"documents":{` + fixture.workload + `"tenant":"` + tenant + `"}}`) + if err == nil { + t.Fatal("a workload that can never name a real object must not parse: " + + "every mint for it would be refused as an identity mismatch, accusing the caller") + } + if !strings.Contains(err.Error(), "documents") || !strings.Contains(err.Error(), fixture.field) { + t.Fatalf("the refusal must name the entry and the offending field %q, "+ + "or the operator goes looking at the caller: %v", fixture.field, err) + } + }) + } +} diff --git a/module/services/accounts/code/pkg/business/module_capability_enforcement.go b/module/services/accounts/code/pkg/business/module_capability_enforcement.go new file mode 100644 index 000000000..bf401d905 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_capability_enforcement.go @@ -0,0 +1,399 @@ +package business + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Enforcement at USE: every capability decision re-reads the live authority +// rather than trusting what the credential sealed. +// +// WHY BOTH HALVES BIND. A credential seals a ceiling at mint time, and the +// declared registry says what a module principal may ever do. Neither alone is +// enough: +// +// - re-reading alone means a WIDENED grant, or a database restored to a +// broader state, retroactively widens a credential already in flight; +// - sealing alone means a NARROWED grant does not take effect until the +// outstanding credential expires. +// +// So the effective authority is the intersection — `min(sealed, live)` — and the +// live half is this file. Narrowing takes effect immediately through the +// re-read; widening never reaches a credential already issued, because the +// sealed ceiling bounds it. +// +// THE REVOCATION PREDICATE IS A CONJUNCTION, and each term closes a different +// hole: +// +// - INSTALLATION FRESHNESS. The organisation's installation must still be +// active, and its revision must match what the credential was minted +// against. Without the revision, an uninstall-and-reinstall produces a new +// installation that an old credential would satisfy. +// - PRODUCER EPOCH. Bumped by any narrowing of the principal's envelope. +// Without it, a narrowing revokes only the credential it happened to be +// holding, and the replacement is reminted with the OLD authority — the +// narrowing then takes effect for exactly one credential's lifetime and then +// undoes itself. +// - BINDING REVISION AND INCARNATION. For an operation context, the exact +// binding the credential named, at the revision it named, bound to the +// execution it was minted for. Verification is an EXACT LOOKUP of that +// binding, never a search of the principal's bindings for one that happens +// to contain the presented scopes. +// +// WHAT IS NOT BUILT YET is named here rather than implied, because this file is +// what a reader will check: the sealed half needs core's Work Context, which +// this host has not cut over to, so `SealedAuthority` below is populated from +// the credential only where the credential carries it. Until the cutover, the +// intersection is the live half AND whatever the credential could carry — which +// is strictly stronger than what preceded it (nothing) and strictly weaker than +// the target. + +// ErrModuleAuthorityStale reports that a capability's live authority no longer +// matches what it was minted against. +// +// Distinct from "not a registered module principal": that one is an identity +// this host never knew, while this is an identity whose authority MOVED. A +// client's right response differs — the first is terminal, the second is +// re-mint-once. +var ErrModuleAuthorityStale = errors.New("module capability authority has moved since it was minted") + +// ErrModuleInstallationInactive reports that an installation the credential +// NAMED is revoked, absent, or another organisation's. +// +// It is not returned for a credential that names no installation. That +// distinction is the difference between a check and the regression this replaced: +// nothing relates a module principal to an installation, so treating "I could +// not find one" as "it is revoked" denied every real module caller. +var ErrModuleInstallationInactive = errors.New("the installation this capability names is not active in this organization") + +// ErrModuleAuthorityUnreadable reports a malformed caller the live read refuses +// before it reaches the database. +// +// Separate from a database failure so that a missing bound organisation does not +// surface as Unavailable, which would read as an outage and be retried forever. +var ErrModuleAuthorityUnreadable = errors.New("live module authority cannot be read for this caller") + +// statusWrapped carries a gRPC status AND wraps a sentinel error. +// +// Both are needed and neither substitutes for the other. The adapter layer turns +// the status into a wire code, while callers inside this package branch on the +// sentinel with errors.Is — a deferred worker deciding whether work is terminal +// is the case that matters. `status.Errorf("%s", sentinel)` FORMATS the sentinel +// into the message and does not wrap it, so errors.Is returns false while the +// text looks right: the first version of this file did exactly that, and the +// tests caught it only because they asserted on the chain rather than on the +// string. +type statusWrapped struct { + status *status.Status + cause error +} + +func (e *statusWrapped) Error() string { return e.status.Message() } +func (e *statusWrapped) Unwrap() error { return e.cause } +func (e *statusWrapped) GRPCStatus() *status.Status { return e.status } + +// wrapStatus builds an error carrying both a code and a cause. +func wrapStatus(code codes.Code, cause error, format string, args ...any) error { + message := fmt.Sprintf("%s: %s", cause, fmt.Sprintf(format, args...)) + return &statusWrapped{status: status.New(code, message), cause: cause} +} + +// ModuleCapabilityAuthority is the authority one capability decision may act on: +// the declared ceiling, and the live facts it was re-read against. +type ModuleCapabilityAuthority struct { + Caller ModuleCaller + Grant ModulePrincipalGrant + + // InstallationID and InstallationRevision are the live installation this + // capability acts under. Carried so a caller that needs the installation — + // to scope a queue read, to attribute a produced record — uses the one that + // was CHECKED rather than re-reading and possibly getting a different one. + InstallationID string + InstallationRevision uint64 + + // ProducerEpoch is the principal's envelope epoch as re-read. A deferred + // producer records it so work it enqueues can be refused later if the + // epoch has moved by the time the work runs. + ProducerEpoch uint64 +} + +// ModuleAuthorityStore is the live authority read. +// +// One method, deliberately: the terms are read at ONE snapshot, because reading +// them separately lets a revoke land between two reads and produce a decision +// describing a state that never existed. +type ModuleAuthorityStore interface { + // LiveModuleAuthority reads the authority facts for one module principal. + // + // installationID is the installation the CREDENTIAL named, or empty when it + // names none. It is a parameter rather than something the implementation + // infers, because nothing relates a module principal to an installation: an + // installation is an organisation's consent to a solution TARGET and the + // module principal is not a party to it. An implementation that guessed + // would answer "revoked" for every legitimate caller — which is exactly what + // the first Postgres implementation did, joining the installation's + // per-install agent principal against the module's deterministic one. + // + // Returns ErrModuleInstallationInactive when a NAMED installation is + // revoked, absent, or another organisation's. Naming none is not an error. + LiveModuleAuthority(ctx context.Context, principalID, orgID, installationID string) (*LiveModuleAuthority, error) +} + +// LiveModuleAuthority is what the live read answers. +type LiveModuleAuthority struct { + InstallationID string + InstallationRevision uint64 + ProducerEpoch uint64 +} + +// AuthorizeModuleCapability is the one place a capability decision is made. +// +// Every capability path calls this — the full inventory, enumerated and enforced +// by TestEveryModuleCapabilityPathReReadsLiveAuthority. That test exists because +// the alternative is a convention: 25 call sites that each remember to re-read, +// and a 26th added next month that does not. A coverage gate is the only thing +// that makes "every path" true rather than aspirational. +// +// The declared grant is resolved FIRST and the live read second. That order is +// deliberate: an unknown principal must be refused without a database round +// trip, both because it is cheaper and because an unauthenticated caller must +// not be able to make this host do work. +func (s *Service) AuthorizeModuleCapability( + ctx context.Context, caller ModuleCaller, +) (*ModuleCapabilityAuthority, error) { + grant, err := s.moduleGrant(caller) + if err != nil { + return nil, err + } + if s.moduleAuthority == nil { + // Fail closed. A host with no live authority read cannot honour a + // narrowing, so it must not authorize capabilities at all rather than + // authorize them on the declared ceiling alone — which is the state this + // file exists to end. + return nil, status.Error(codes.FailedPrecondition, + "this host cannot re-read live module authority, so no capability may be exercised") + } + + // The installation the credential named, when it names one. Passed in + // rather than inferred: see ModuleAuthorityStore. + var sealedInstallation string + if caller.Sealed != nil && caller.Sealed.InstallationID != nil { + sealedInstallation = *caller.Sealed.InstallationID + } + live, err := s.moduleAuthority.LiveModuleAuthority( + ctx, caller.PrincipalID, caller.BoundOrg, sealedInstallation) + if err != nil { + if errors.Is(err, ErrModuleInstallationInactive) { + return nil, wrapStatus(codes.PermissionDenied, ErrModuleInstallationInactive, + "principal %s in organization %s", caller.PrincipalID, caller.BoundOrg) + } + if errors.Is(err, ErrModuleAuthorityUnreadable) { + return nil, wrapStatus(codes.InvalidArgument, ErrModuleAuthorityUnreadable, + "principal %s", caller.PrincipalID) + } + // The live read failed. NOT a denial and NOT an allow: the host does not + // know whether this capability is still authorized, so it answers + // Unavailable and the caller retries. Treating it as a denial would make + // a database blip look like mass revocation; as an allow, it would be + // the check not running. + return nil, status.Errorf(codes.Unavailable, "cannot re-read live module authority: %v", err) + } + + authority := &ModuleCapabilityAuthority{ + Caller: caller, + Grant: grant, + InstallationID: live.InstallationID, + InstallationRevision: live.InstallationRevision, + ProducerEpoch: live.ProducerEpoch, + } + if err := authority.checkAgainstSealed(caller.Sealed); err != nil { + return nil, err + } + return authority, nil +} + +// checkAgainstSealed is the intersection: the live facts must still match what +// the credential was minted against. +// +// An ABSENT sealed value is not a match and not a mismatch — it is a credential +// that carries no claim about that term, which is the state every credential is +// in until the Work Context cutover. Those are skipped rather than defaulted to +// zero, and the difference matters: zero would compare equal to a real +// installation revision of 0 and would therefore PASS for exactly the +// credentials that claim nothing. +// +// That asymmetry is the one trap in this function. "Treat missing as zero" is +// the shorter implementation and it silently admits every unsealed credential +// while looking like a check. +func (a *ModuleCapabilityAuthority) checkAgainstSealed(sealed *SealedModuleAuthority) error { + if sealed == nil { + return nil + } + if sealed.InstallationID != nil && *sealed.InstallationID != a.InstallationID { + return wrapStatus(codes.Unauthenticated, ErrModuleAuthorityStale, + "sealed for installation %s, live installation is %s", + *sealed.InstallationID, a.InstallationID) + } + if sealed.InstallationRevision != nil && *sealed.InstallationRevision != a.InstallationRevision { + return wrapStatus(codes.Unauthenticated, ErrModuleAuthorityStale, + "sealed at installation revision %d, live revision is %d", + *sealed.InstallationRevision, a.InstallationRevision) + } + if sealed.ProducerEpoch != nil && *sealed.ProducerEpoch != a.ProducerEpoch { + return wrapStatus(codes.Unauthenticated, ErrModuleAuthorityStale, + "sealed at producer epoch %d, live epoch is %d", + *sealed.ProducerEpoch, a.ProducerEpoch) + } + return nil +} + +// SealedModuleAuthority is what a credential claimed at mint. +// +// Every field is a POINTER, and that is the whole design of this type. A +// credential that carries no installation revision is making no claim about one, +// and a value type could not express that: zero would be indistinguishable from +// "revision 0", which is a real revision a credential could legitimately have +// been sealed at. Core's own seal made the same change for the same reason — +// `build_incarnation` and `image_digest` became optional precisely so "bears no +// execution" and "bears execution zero" stopped being the same bytes. +// +// So: ask about presence, never compare zero. +type SealedModuleAuthority struct { + InstallationID *string + InstallationRevision *uint64 + ProducerEpoch *uint64 + // BindingID and BindingRevision are set for an operation context only. + // Verification against them is an EXACT lookup of that binding — never a + // search of the principal's bindings for one containing the presented + // scopes, which is what this host did and what must go. + BindingID *string + BindingRevision *uint64 +} + +// ExactOperationBinding resolves the one binding a credential named. +// +// The name says what it is NOT: a search. `operationScopesSubset` walked the +// principal's bindings looking for one whose scopes contained the presented set, +// which means a credential naming a narrow binding was satisfied by any WIDER +// binding the principal also held — so narrowing one binding achieved nothing +// while a broader one survived. +// +// An absent sealed binding id is refused rather than falling back to a search. +// The fallback is the whole defect: it is what makes the exact lookup optional, +// and an optional exact lookup is a search with extra steps. +func (s *Service) ExactOperationBinding( + ctx context.Context, authority *ModuleCapabilityAuthority, sealed *SealedModuleAuthority, +) (*ModuleOperationBinding, error) { + if sealed == nil || sealed.BindingID == nil || *sealed.BindingID == "" { + return nil, status.Error(codes.Unauthenticated, + "this capability names no operation binding, and a binding is resolved by exact lookup rather than by searching for one with compatible scopes") + } + if s.moduleOperationBindings == nil { + return nil, status.Error(codes.FailedPrecondition, + "this host cannot resolve an operation binding, so no operation capability may be exercised") + } + binding, err := s.moduleOperationBindings.OperationBindingByID( + ctx, authority.Caller.PrincipalID, *sealed.BindingID) + if err != nil { + return nil, status.Errorf(codes.Unavailable, "cannot resolve operation binding: %v", err) + } + if binding == nil { + return nil, status.Errorf(codes.PermissionDenied, + "operation binding %s is not held by principal %s", *sealed.BindingID, authority.Caller.PrincipalID) + } + if sealed.BindingRevision != nil && *sealed.BindingRevision != binding.Revision { + return nil, wrapStatus(codes.Unauthenticated, ErrModuleAuthorityStale, + "sealed at binding revision %d, live revision is %d", + *sealed.BindingRevision, binding.Revision) + } + return binding, nil +} + +// ModuleOperationBinding is one binding as the exact lookup returns it. +type ModuleOperationBinding struct { + BindingID string + Revision uint64 + Audience string + Scopes []string +} + +// ModuleOperationBindingStore is the exact-lookup surface. +// +// By ID and principal, with no listing method — deliberately. A `List` here +// would be the search this replaces, available to whoever reached for it next. +type ModuleOperationBindingStore interface { + OperationBindingByID(ctx context.Context, principalID, bindingID string) (*ModuleOperationBinding, error) +} + +// DeferredWorkAuthority is what a deferred producer records so the work it +// enqueues can be re-checked when it RUNS. +// +// Deferred work is the case the live re-read alone cannot cover: the authority +// was current when the job was enqueued and the job runs later, so "re-read at +// use" has to mean at the moment of USE and not at the moment of enqueue. The +// producer therefore stamps what it was authorized under, and the worker refuses +// work whose stamp no longer matches. +type DeferredWorkAuthority struct { + PrincipalID string + OrganizationID string + InstallationID string + InstallationRevision uint64 + ProducerEpoch uint64 +} + +// StampDeferredWork captures the authority a producer acted under. +func (a *ModuleCapabilityAuthority) StampDeferredWork() DeferredWorkAuthority { + return DeferredWorkAuthority{ + PrincipalID: a.Caller.PrincipalID, + OrganizationID: a.Caller.BoundOrg, + InstallationID: a.InstallationID, + InstallationRevision: a.InstallationRevision, + ProducerEpoch: a.ProducerEpoch, + } +} + +// ErrDeferredWorkRevoked reports that work was enqueued under authority that has +// since moved. +// +// Its TERMINAL OUTCOME is defined here rather than left to each worker, because +// the alternative is that each one guesses. Work whose authority was revoked is +// not retried and not silently dropped: it is failed with this error, which a +// worker records as a terminal state. Retrying would mean asking the same +// question forever, and dropping it would lose the evidence that something was +// enqueued under authority that no longer exists. +var ErrDeferredWorkRevoked = errors.New("deferred work was enqueued under authority that has since been revoked") + +// AuthorizeDeferredWork re-checks a stamp when the work runs. +// +// Already-leased work gets its outcome from here: if the authority has moved, the +// work fails terminally rather than being retried, because the authority will +// not come back by waiting. +func (s *Service) AuthorizeDeferredWork( + ctx context.Context, stamp DeferredWorkAuthority, +) (*ModuleCapabilityAuthority, error) { + sealed := &SealedModuleAuthority{ + InstallationID: &stamp.InstallationID, + InstallationRevision: &stamp.InstallationRevision, + ProducerEpoch: &stamp.ProducerEpoch, + } + authority, err := s.AuthorizeModuleCapability(ctx, ModuleCaller{ + PrincipalID: stamp.PrincipalID, + BoundOrg: stamp.OrganizationID, + Sealed: sealed, + }) + if err != nil { + if errors.Is(err, ErrModuleAuthorityStale) || + status.Code(err) == codes.PermissionDenied || + status.Code(err) == codes.Unauthenticated { + return nil, fmt.Errorf("%w: %w", ErrDeferredWorkRevoked, err) + } + // Unavailable: the host could not check. The work is retryable, because + // nothing was decided. + return nil, err + } + return authority, nil +} diff --git a/module/services/accounts/code/pkg/business/module_capability_enforcement_test.go b/module/services/accounts/code/pkg/business/module_capability_enforcement_test.go new file mode 100644 index 000000000..7fa4e29bf --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_capability_enforcement_test.go @@ -0,0 +1,433 @@ +//go:build pure + +package business + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Enforcement at use, and every way a narrowing could fail to reach a +// capability. +// +// The coverage gate in module/tools proves every path is ROUTED through the +// enforcing wrapper. These prove the wrapper DECIDES correctly — the two are +// different claims, and a gate alone would hold 25 paths to a check that did +// nothing. + +type fakeModuleAuthority struct { + live *LiveModuleAuthority + err error + // calls counts the live reads, so a test can prove the check actually ran + // rather than inferring it from a verdict that might have other causes. + calls int + // namedInstallation is the installation id the caller's credential named, + // as passed to the read. + namedInstallation string +} + +func (f *fakeModuleAuthority) LiveModuleAuthority( + _ context.Context, _, _, installationID string, +) (*LiveModuleAuthority, error) { + f.calls++ + // Recorded so a test can prove the NAMED installation reached the read, + // rather than the read having inferred one — the regression that broke + // every capability path inferred it. + f.namedInstallation = installationID + if f.err != nil { + return nil, f.err + } + return f.live, nil +} + +type fakeOperationBindings struct { + byID map[string]*ModuleOperationBinding + err error + // asked records every binding id looked up, which is how a SEARCH is + // distinguished from an exact lookup: a search would ask for none of them by + // id and instead enumerate. + asked []string +} + +func (f *fakeOperationBindings) OperationBindingByID( + _ context.Context, _, bindingID string, +) (*ModuleOperationBinding, error) { + f.asked = append(f.asked, bindingID) + if f.err != nil { + return nil, f.err + } + return f.byID[bindingID], nil +} + +// enforcingService wires a service whose registry declares one module principal. +func enforcingService(authority ModuleAuthorityStore, bindings ModuleOperationBindingStore) *Service { + service := &Service{store: noopControlPlaneStore{}} + service.SetModulePrincipals(ModulePrincipalRegistry{ + "principal-1": {Prefix: "crm", Tenant: "org-1", Queues: []string{"a"}}, + }) + service.SetModuleAuthorityReads(authority, bindings) + return service +} + +func caller() ModuleCaller { + return ModuleCaller{PrincipalID: "principal-1", BoundOrg: "org-1"} +} + +func liveAt(revision, epoch uint64) *fakeModuleAuthority { + return &fakeModuleAuthority{live: &LiveModuleAuthority{ + InstallationID: "install-1", + InstallationRevision: revision, + ProducerEpoch: epoch, + }} +} + +// --------------------------------------------------------------------------- +// The live read happens at all +// --------------------------------------------------------------------------- + +// The declared ceiling is not enough on its own: the live installation is read +// on every decision. +func TestCapabilityReReadsLiveAuthority(t *testing.T) { + authority := liveAt(5, 2) + service := enforcingService(authority, nil) + + got, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.NoError(t, err) + require.Equal(t, 1, authority.calls, "the live installation must be read, not assumed") + require.Equal(t, "install-1", got.InstallationID) + require.Equal(t, uint64(5), got.InstallationRevision) + require.Equal(t, uint64(2), got.ProducerEpoch) + require.Equal(t, []string{"a"}, got.Grant.Queues, "the declared ceiling still bounds it") +} + +// An uninstalled solution's capability is refused, and the refusal is a VERDICT. +func TestCapabilityIsRefusedWhenTheInstallationIsInactive(t *testing.T) { + service := enforcingService(&fakeModuleAuthority{err: ErrModuleInstallationInactive}, nil) + + _, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +// A host that CANNOT re-read answers Unavailable — neither a denial nor an +// allow. +// +// Treating it as a denial would make a database blip look like mass revocation; +// treating it as an allow would be the check not running. The distinction is the +// same one the Kubernetes client makes, for the same reason. +func TestCapabilityIsUndecidableWhenTheLiveReadFails(t *testing.T) { + service := enforcingService(&fakeModuleAuthority{err: errors.New("connection reset")}, nil) + + _, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.Equal(t, codes.Unavailable, status.Code(err)) + require.NotEqual(t, codes.PermissionDenied, status.Code(err), + "an unreadable authority is not a revoked one") +} + +// A host with no live read at all refuses every capability rather than falling +// back to the declared ceiling — which is the state enforcement at use exists to +// end. +func TestCapabilityIsRefusedWithoutALiveReadWired(t *testing.T) { + service := &Service{store: noopControlPlaneStore{}} + service.SetModulePrincipals(ModulePrincipalRegistry{ + "principal-1": {Prefix: "crm", Tenant: "org-1"}, + }) + + _, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) +} + +// An unknown principal is refused WITHOUT a live read: the registry is the +// allowlist, and an unauthenticated caller must not be able to make this host do +// database work. +func TestUnknownPrincipalIsRefusedBeforeTheLiveRead(t *testing.T) { + authority := liveAt(1, 1) + service := enforcingService(authority, nil) + + _, err := service.AuthorizeModuleCapability(context.Background(), + ModuleCaller{PrincipalID: "stranger", BoundOrg: "org-1"}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Zero(t, authority.calls, "an unknown principal must not cost a database round trip") +} + +// --------------------------------------------------------------------------- +// min(sealed, live): the intersection, and the absence trap +// --------------------------------------------------------------------------- + +// A credential sealed at an older installation revision is refused: the +// authority moved after it was minted. +// +// Without the revision, an uninstall-and-reinstall produces a new installation +// that the old credential would satisfy. +func TestSealedStaleInstallationRevisionIsRefused(t *testing.T) { + service := enforcingService(liveAt(6, 2), nil) + + sealedRevision := uint64(5) + subject := caller() + subject.Sealed = &SealedModuleAuthority{InstallationRevision: &sealedRevision} + + _, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.ErrorIs(t, err, ErrModuleAuthorityStale) + require.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +// A credential sealed at an older producer epoch is refused. +// +// This is the term that stops a narrowing undoing itself: without it, narrowing +// revokes only the credential being held and the replacement is reminted with +// the old authority. +func TestSealedStaleProducerEpochIsRefused(t *testing.T) { + service := enforcingService(liveAt(5, 3), nil) + + sealedEpoch := uint64(2) + subject := caller() + subject.Sealed = &SealedModuleAuthority{ProducerEpoch: &sealedEpoch} + + _, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.ErrorIs(t, err, ErrModuleAuthorityStale) +} + +// A credential sealed for a DIFFERENT installation is refused even at a matching +// revision — the installation id is the identity, and revisions are per +// organisation. +func TestSealedForAnotherInstallationIsRefused(t *testing.T) { + service := enforcingService(liveAt(5, 2), nil) + + other := "install-elsewhere" + subject := caller() + subject.Sealed = &SealedModuleAuthority{InstallationID: &other} + + _, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.ErrorIs(t, err, ErrModuleAuthorityStale) +} + +// Matching seals pass. Without this the refusal tests could pass against a +// wrapper that refuses everything sealed. +func TestMatchingSealsPass(t *testing.T) { + service := enforcingService(liveAt(5, 2), nil) + + id, revision, epoch := "install-1", uint64(5), uint64(2) + subject := caller() + subject.Sealed = &SealedModuleAuthority{ + InstallationID: &id, InstallationRevision: &revision, ProducerEpoch: &epoch, + } + + got, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.NoError(t, err) + require.Equal(t, "install-1", got.InstallationID) +} + +// THE ABSENCE TRAP, and the single most important test in this file. +// +// A credential that carries NO claim about a term must not be compared against +// zero. "Treat missing as zero" is the shorter implementation and it silently +// admits every unsealed credential while looking like a check — and every +// credential is unsealed until the Work Context cutover, so it would admit all +// of them. +// +// A live revision of ZERO is the case that exposes it: with pointers, an absent +// seal is skipped and a zero seal is compared. Flatten them to values and the +// two become indistinguishable. +func TestAbsentSealIsNotComparedAgainstZero(t *testing.T) { + // Live authority at revision 0 and epoch 0 — real values a fresh + // organisation genuinely has. + service := enforcingService(liveAt(0, 0), nil) + + // A credential claiming nothing passes: there is nothing to intersect. + unsealed := caller() + _, err := service.AuthorizeModuleCapability(context.Background(), unsealed) + require.NoError(t, err, "a credential that claims nothing is bounded by the live read alone") + + // A credential explicitly claiming revision 0 also passes, because 0 is a + // real revision and it matches. + zero := uint64(0) + claimsZero := caller() + claimsZero.Sealed = &SealedModuleAuthority{InstallationRevision: &zero} + _, err = service.AuthorizeModuleCapability(context.Background(), claimsZero) + require.NoError(t, err) + + // And one claiming revision 0 against a LIVE revision of 1 is refused — which + // is what distinguishes "claims zero" from "claims nothing". + moved := enforcingService(liveAt(1, 0), nil) + _, err = moved.AuthorizeModuleCapability(context.Background(), claimsZero) + require.ErrorIs(t, err, ErrModuleAuthorityStale, + "a credential claiming revision 0 is making a claim, and it is now wrong") + + // While the one claiming nothing still passes against the same live state. + _, err = moved.AuthorizeModuleCapability(context.Background(), unsealed) + require.NoError(t, err, "absence is not a claim about zero") +} + +// A PARTIALLY sealed credential is the case that actually catches "treat missing +// as zero", and the case above does not. +// +// A wholly-nil seal short-circuits before any field is compared, so a mutation +// that flattens a missing FIELD to zero slips past it — which is exactly what +// happened: the first version of the absence test passed against that mutation, +// and only this case fails. +// +// It is also the realistic shape during the Work Context cutover: a credential +// that carries the installation id because the mint can answer it, and no +// revision because the seal does not yet have the field. Flatten the missing +// revision to zero, compare it against a live revision of 1, and every such +// credential is refused — the check inverts from admitting too much to refusing +// everything, and both are wrong. +func TestPartiallySealedCredentialSkipsOnlyTheTermsItOmits(t *testing.T) { + // Live at revision 1 and epoch 1, so a flattened zero would MISMATCH. + service := enforcingService(liveAt(1, 1), nil) + + id := "install-1" + partial := caller() + partial.Sealed = &SealedModuleAuthority{ + // Claims the installation, and deliberately nothing else. + InstallationID: &id, + } + + got, err := service.AuthorizeModuleCapability(context.Background(), partial) + require.NoError(t, err, + "a credential claiming the installation but no revision must be bounded by the term it claims, "+ + "not refused for a term it is silent about") + require.Equal(t, uint64(1), got.InstallationRevision) + + // And the term it DOES claim is still enforced. + elsewhere := "install-elsewhere" + wrong := caller() + wrong.Sealed = &SealedModuleAuthority{InstallationID: &elsewhere} + _, err = service.AuthorizeModuleCapability(context.Background(), wrong) + require.ErrorIs(t, err, ErrModuleAuthorityStale) +} + +// --------------------------------------------------------------------------- +// The exact binding lookup, which is NOT a search +// --------------------------------------------------------------------------- + +// The binding is resolved by EXACT id, and the id asked for is the one the +// credential sealed. +// +// `operationScopesSubset` searched the principal's bindings for one whose scopes +// contained the presented set — so a credential naming a narrow binding was +// satisfied by any WIDER binding the principal also held, and narrowing one +// binding achieved nothing while a broader one survived. +func TestOperationBindingIsResolvedByExactID(t *testing.T) { + bindings := &fakeOperationBindings{byID: map[string]*ModuleOperationBinding{ + "binding-narrow": {BindingID: "binding-narrow", Revision: 3, Scopes: []string{"read"}}, + "binding-wide": {BindingID: "binding-wide", Revision: 1, Scopes: []string{"read", "write"}}, + }} + service := enforcingService(liveAt(5, 2), bindings) + + sealedID, sealedRevision := "binding-narrow", uint64(3) + subject := caller() + subject.Sealed = &SealedModuleAuthority{BindingID: &sealedID, BindingRevision: &sealedRevision} + + authority, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.NoError(t, err) + + binding, err := service.ExactOperationBinding(context.Background(), authority, subject.Sealed) + require.NoError(t, err) + require.Equal(t, "binding-narrow", binding.BindingID) + require.Equal(t, []string{"binding-narrow"}, bindings.asked, + "exactly the sealed binding is looked up — the wider one is never consulted") +} + +// A capability naming NO binding is refused rather than falling back to a +// search. The fallback is the whole defect: an optional exact lookup is a search +// with extra steps. +func TestOperationBindingWithoutASealedIDIsRefused(t *testing.T) { + bindings := &fakeOperationBindings{byID: map[string]*ModuleOperationBinding{ + "binding-wide": {BindingID: "binding-wide", Scopes: []string{"read", "write"}}, + }} + service := enforcingService(liveAt(5, 2), bindings) + + authority, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.NoError(t, err) + + _, err = service.ExactOperationBinding(context.Background(), authority, nil) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + require.Empty(t, bindings.asked, "no binding may be consulted when none was named") +} + +// A binding the principal does not hold is refused, not resolved to a similar +// one. +func TestOperationBindingNotHeldIsRefused(t *testing.T) { + bindings := &fakeOperationBindings{byID: map[string]*ModuleOperationBinding{}} + service := enforcingService(liveAt(5, 2), bindings) + + sealedID := "binding-elsewhere" + subject := caller() + subject.Sealed = &SealedModuleAuthority{BindingID: &sealedID} + authority, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.NoError(t, err) + + _, err = service.ExactOperationBinding(context.Background(), authority, subject.Sealed) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +// A binding whose revision has moved is refused: the binding is the same one, +// and what it authorises has changed. +func TestOperationBindingAtAStaleRevisionIsRefused(t *testing.T) { + bindings := &fakeOperationBindings{byID: map[string]*ModuleOperationBinding{ + "binding-1": {BindingID: "binding-1", Revision: 4}, + }} + service := enforcingService(liveAt(5, 2), bindings) + + sealedID, sealedRevision := "binding-1", uint64(3) + subject := caller() + subject.Sealed = &SealedModuleAuthority{BindingID: &sealedID, BindingRevision: &sealedRevision} + authority, err := service.AuthorizeModuleCapability(context.Background(), subject) + require.NoError(t, err) + + _, err = service.ExactOperationBinding(context.Background(), authority, subject.Sealed) + require.ErrorIs(t, err, ErrModuleAuthorityStale) +} + +// --------------------------------------------------------------------------- +// Deferred work: re-checked when it RUNS, with a defined terminal outcome +// --------------------------------------------------------------------------- + +// Deferred work is the case the live re-read alone cannot cover: the authority +// was current when the job was enqueued and the job runs later, so "at use" has +// to mean at the moment of use. +func TestDeferredWorkIsReCheckedWhenItRuns(t *testing.T) { + service := enforcingService(liveAt(5, 2), nil) + authority, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.NoError(t, err) + stamp := authority.StampDeferredWork() + + // Still current: the work runs. + _, err = service.AuthorizeDeferredWork(context.Background(), stamp) + require.NoError(t, err) + + // The epoch moves — a narrowing landed between enqueue and run. + moved := enforcingService(liveAt(5, 3), nil) + _, err = moved.AuthorizeDeferredWork(context.Background(), stamp) + require.ErrorIs(t, err, ErrDeferredWorkRevoked, + "work enqueued under authority that has since been revoked must not run") +} + +// Revoked deferred work is TERMINAL, not retryable: the authority will not come +// back by waiting, so retrying asks the same question forever. An unreadable +// authority IS retryable, because nothing was decided. +// +// Both in one test, because the distinction is the point and either alone could +// be satisfied by collapsing them. +func TestRevokedDeferredWorkIsTerminalWhileAnOutageIsRetryable(t *testing.T) { + service := enforcingService(liveAt(5, 2), nil) + authority, err := service.AuthorizeModuleCapability(context.Background(), caller()) + require.NoError(t, err) + stamp := authority.StampDeferredWork() + + revoked := enforcingService(&fakeModuleAuthority{err: ErrModuleInstallationInactive}, nil) + _, err = revoked.AuthorizeDeferredWork(context.Background(), stamp) + require.ErrorIs(t, err, ErrDeferredWorkRevoked, "a revoked installation is terminal") + + unreadable := enforcingService(&fakeModuleAuthority{err: errors.New("connection reset")}, nil) + _, err = unreadable.AuthorizeDeferredWork(context.Background(), stamp) + require.NotErrorIs(t, err, ErrDeferredWorkRevoked, + "an unreadable authority must stay retryable — nothing was decided") + require.Equal(t, codes.Unavailable, status.Code(err)) +} diff --git a/module/services/accounts/code/pkg/business/module_datasource_blob_test.go b/module/services/accounts/code/pkg/business/module_datasource_blob_test.go index 246e5c3dc..5244c2c31 100644 --- a/module/services/accounts/code/pkg/business/module_datasource_blob_test.go +++ b/module/services/accounts/code/pkg/business/module_datasource_blob_test.go @@ -20,6 +20,7 @@ func moduleBlobService(t *testing.T, store business.Store, gh business.GitHubCon t.Helper() svc, audit := newDatasourceService(store, &recordingProducer{}, gh) _ = audit + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(&fakeJobBackend{}, &fakeJobBackend{}, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: queues, CrossTenant: crossTenant}, }) @@ -140,6 +141,7 @@ func TestModuleFetchDatasourceBlob_ConnectorUnconfigured(t *testing.T) { if err != nil { t.Fatalf("NewService: %v", err) } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(&fakeJobBackend{}, &fakeJobBackend{}, business.ModulePrincipalRegistry{ modulePrincSvc: {Queues: []string{"datasource"}, CrossTenant: true}, }) diff --git a/module/services/accounts/code/pkg/business/module_execution_binding.go b/module/services/accounts/code/pkg/business/module_execution_binding.go new file mode 100644 index 000000000..26d199cad --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_execution_binding.go @@ -0,0 +1,424 @@ +package business + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// Execution-bound minting: what the caller IS RUNNING, established independently +// of anything the caller says. +// +// WHAT THIS REPLACES. The mint authenticated with a shared secret, which is a +// bearer token: whoever holds it is the module, so the mint could not answer what +// the caller was running at all. A secret cannot be execution-bound in principle +// — it says "I know the secret", never "I am this pod running this image". +// +// THE THREE DIGEST TYPES, and why conflating any two defeats the check. Each +// answers a different question and they are deliberately separate types below +// rather than three strings: +// +// - APPROVED (`ApprovedDigest`) — what the authority document says may run. +// Signed, delivered out of band, and the only one the caller has no influence +// over. +// - DECLARED (`DeclaredDigest`) — what the pod's SPEC asks to run. Writable by +// whoever can create the pod, so it is evidence of intent and never of fact. +// - RUNNING (`RunningDigest`) — what the container's STATUS reports it is +// actually running, as `imageID`. +// +// The check is APPROVED == RUNNING. Comparing approved against declared is the +// classic defeat: a pod spec can name an approved image and run something else +// if the tag moved, and the comparison passes while the workload is unapproved. +// Comparing declared against running proves only that the pod got what it asked +// for, which says nothing about whether it was allowed to ask. +// +// THE TAUTOLOGY THIS AVOIDS. Filling both sides of the comparison from the same +// source — reading the approved digest out of the authority document AND the +// running digest out of a field the same document provides — passes for every +// caller, because it compares a value to itself. The approved digest comes from +// the signed document; the running digest comes from the Kubernetes API, keyed by +// a pod UID that came from a TokenReview of a token the caller could not forge. +// Three independent sources, and no single one of them can satisfy the check. +// +// NOTHING IS ISSUED WHEN THE API IS UNAVAILABLE. An unreachable control plane +// means the host cannot establish what the caller is running, and a mint that +// proceeded would issue an unbound capability exactly when binding was +// impossible — the one circumstance in which the check matters most. That is not +// the same answer as a refusal, and the two are kept distinct below so a caller +// can tell "you are not approved" from "I could not tell". + +// ErrExecutionUnbound reports that the host could not establish what the caller +// is running. NOT a denial: the caller may be perfectly approved. +var ErrExecutionUnbound = errors.New("cannot establish the caller's execution, so no capability may be minted") + +// ErrExecutionNotApproved reports that the caller IS running something the +// authority document does not approve. A verdict. +var ErrExecutionNotApproved = errors.New("the caller is running an image the authority document does not approve") + +// ErrExecutionIdentityMismatch reports that the token and the pod disagree — +// the token named a pod whose live UID differs, or whose container is absent. +var ErrExecutionIdentityMismatch = errors.New("the presented token's pod does not match the live pod") + +// ErrExecutionNotBoundToken reports a token that names no pod at all. +// +// A legacy non-bound service account token authenticates the SERVICE ACCOUNT and +// nothing narrower, so it cannot be checked against a pod. Refused rather than +// skipped: skipping would make the whole check opt-out by presenting an older +// token, which is the easiest possible bypass. +var ErrExecutionNotBoundToken = errors.New("the presented token names no pod, so its execution cannot be established") + +// ApprovedDigest is what the authority document approves. Signed, out of band. +type ApprovedDigest string + +// RunningDigest is what a container's status reports it is running. +type RunningDigest string + +// DeclaredDigest is what a pod's spec asks to run. +// +// It is READ and REPORTED, never compared against an approved digest. Three +// distinct types rather than three strings is what makes the wrong comparison a +// compile error instead of a code-review question. +// +// A previous commit on this branch deleted this type as dead code, because +// nothing read a pod's spec and a type with no referent is decoration. That was +// right about the type and wrong about the gap: the fix is to read the value, not +// to drop the name. It is now populated from the pod's spec, so a refusal can +// report that a pod ASKED for the approved image and is RUNNING something else — +// which is the signature of a moved tag, and the exact defeat that comparing +// approved against declared would wave through. +type DeclaredDigest string + +// ExecutionIdentity is what the host established about a caller, independently. +type ExecutionIdentity struct { + PrincipalID string + Namespace string + // ServiceAccount, PodName and PodUID come from the TokenReview. The UID is + // what is compared, because a pod NAME is reused across generations of a + // workload and the UID is not. + ServiceAccount string + PodName string + PodUID string + ContainerName string + // Running is what the container's status reports. The authority document's + // approved digest is compared against THIS. + Running RunningDigest + // Declared is what the pod's spec asked for. Carried for diagnosis only; + // comparing the approved digest against it is the defeat this separation + // exists to prevent, and the types make that comparison not compile. + Declared DeclaredDigest + // Incarnation is the approved build's counter, carried so a seal can name + // it. It comes from the authority document, never from the pod: a pod + // cannot be asked which generation of approval it belongs to. + Incarnation uint64 +} + +// ExecutionReviewer is the independent source: a TokenReview, and a pod read. +// +// Two methods rather than one, because the second needs the first's answer — the +// pod is read by the name and namespace the TOKEN named, so a caller cannot +// point the host at a different pod. +type ExecutionReviewer interface { + // ReviewToken authenticates a token and reports the identity Kubernetes + // attributes to it. The audience is checked by the API server, so a token + // minted for another audience is rejected there rather than here. + ReviewToken(ctx context.Context, token, audience string) (*ReviewedExecutionToken, error) + // RunningContainer reads what a container is running, from the pod's + // status. + RunningContainer(ctx context.Context, namespace, pod, container string) (*RunningContainerStatus, error) +} + +// ReviewedExecutionToken is a TokenReview's answer. +type ReviewedExecutionToken struct { + ServiceAccount string + Namespace string + PodName string + PodUID string +} + +// RunningContainerStatus is a pod read's answer. +type RunningContainerStatus struct { + UID string + // ImageID is from the container's STATUS: what it runs. + ImageID string + // DeclaredImage is from the pod's SPEC: what it asked to run. + DeclaredImage string + Found bool +} + +// ExecutionAuthority answers what a principal's approved build is. +// +// This is the authority document's half, and it takes NO attributes of the +// workload. Resolving by (service account, image digest) is exactly what lets a +// pod from a superseded generation in: it would answer "approved" for whatever +// that pod presents. The issuer answers what IT approves; the caller's running +// image is established separately; a mismatch is a refusal. Core's `SealSource` +// documents the same rule for the same reason. +type ExecutionAuthority interface { + ApprovedBuild(ctx context.Context, principalID string) (ApprovedDigest, uint64, error) +} + +// ErrNoApprovedBuild reports that a principal bears no approved build. +// +// Distinct from an unknown principal and from an unreadable authority, and the +// three-state distinction is the point: +// +// - no approved build → the principal is known and bears none. REFUSED. +// Execution binding is unconditional: a capability carrying no execution is +// one no verifier can hold against a running image, so it is not minted. +// - unknown principal → refused. Nothing is known about it, so nothing can be +// approved for it. +// - unreadable authority → ErrExecutionUnbound. Not a verdict. +// +// All three refuse; the three sentinels stay separate because they send an +// operator to three different places, not because one of them admits. +// +// This comment previously said a capability "may still be minted, carrying NO +// execution" for a caller permitted to act unbound. That posture does not exist +// here, and stating it in a doc comment is itself the hazard: prose describing a +// permissive path is an instruction to build one. If a principal that may act +// unbound is ever genuinely required, it does not arrive by relaxing this — +// it arrives as a principal that is not a module, through its own surface, with +// its own test. +var ErrNoApprovedBuild = errors.New("principal bears no approved build") + +// ErrUnknownExecutionPrincipal reports a principal the authority has no record +// of at all. +var ErrUnknownExecutionPrincipal = errors.New("principal is not known to the execution authority") + +// ErrExecutionAuthorityUnreconciled reports that this host has an authority +// question to answer and has not yet established an answer: a ceiling is +// delivered, and no reconciliation pass has installed a view. +// +// It refuses exactly as the others do — nothing is minted — and exists because +// the alternative was to answer ErrUnknownExecutionPrincipal, which is a +// statement ABOUT THE PRINCIPAL ("never heard of it") and was being returned +// for a state that says nothing about the principal at all. An operator +// debugging a cold start was told their principal did not exist. This file +// argues at length that those two answers must not read alike; reusing one for +// the other contradicted that in the one place it is most expensive, because +// the natural response to "unknown principal" is to go and change the +// declaration that was already correct. +// +// It is NOT returned by a host that answers no authority question at all (no +// envelope, or no delivery inbox wired). That host is a correct, complete +// deployment whose every principal genuinely is unknown and always will be, so +// ErrUnknownExecutionPrincipal is the accurate answer there and activation +// already names the configuration with +// ErrSolutionAuthorityCeilingUnavailable. +var ErrExecutionAuthorityUnreconciled = errors.New( + "the execution authority has completed no reconciliation pass") + +// executionTokenAudience is the audience a caller's token must be minted for. +// +// A token minted for the API server's own audience would authenticate the same +// service account while having been issued for something else entirely, so the +// audience is what makes the token a credential FOR this host. +const executionTokenAudience = "accounts" + +// BindExecution establishes what a caller is running and holds it against what +// the authority document approves. +// +// THE ORDER IS LOAD-BEARING, and it is: authenticate, then read the pod the +// token named, then compare. Reading the pod first would mean reading a pod the +// caller named, which is a caller-supplied input; the token is what makes the +// pod reference trustworthy. +func (s *Service) BindExecution( + ctx context.Context, principalID, token string, +) (*ExecutionIdentity, error) { + if s.executionReviewer == nil || s.executionAuthority == nil { + return nil, fmt.Errorf("%w: this host has no execution reviewer wired", ErrExecutionUnbound) + } + if token == "" { + return nil, fmt.Errorf("%w: no token presented", ErrExecutionNotBoundToken) + } + + // THE DECLARED WORKLOAD, read before the token is reviewed, because it is + // what the review will be held against. A principal this host has no + // declaration for is refused as unknown rather than as unapproved: nothing + // is known about it, so nothing can be approved for it, and the two must + // not read alike. + grant, declared := s.declaredModules()[principalID] + if !declared || !grant.Workload.declared() { + return nil, fmt.Errorf("%w: %s declares no workload identity", ErrUnknownExecutionPrincipal, principalID) + } + // The container comes from the declaration, never from the caller. It used + // to be an argument, which left the caller choosing which of its pod's + // images the approval was tested against — a pod's containers do not all + // run the same image, so naming a sibling picks the comparison. + containerName := grant.Workload.Container + + // 1. Authenticate. The API server checks the audience, so a token minted + // for anything else is rejected before this host reasons about it. + reviewed, err := s.executionReviewer.ReviewToken(ctx, token, executionTokenAudience) + if err != nil { + // Could not reach the API server, or it refused. Both mean the host + // cannot establish the execution; neither means the caller is + // unapproved. The reviewer distinguishes them in its own errors and + // both land here as unbound. + return nil, fmt.Errorf("%w: %w", ErrExecutionUnbound, err) + } + if reviewed.PodUID == "" || reviewed.PodName == "" { + return nil, fmt.Errorf("%w: the token authenticates %s/%s but names no pod", + ErrExecutionNotBoundToken, reviewed.Namespace, reviewed.ServiceAccount) + } + + // THE CALLER MUST BE THE WORKLOAD THE PRINCIPAL DECLARES. Without this the + // principal is a bare claim: a module holding its own valid token could + // name another module's prefix, and the comparison below would test that + // other principal's approved build against THIS caller's running image — + // which passes whenever the two share an image, and a shared base image is + // ordinary. A verdict rather than "unbound": the host established exactly + // who the caller is, and it is not who it says it is. + if reviewed.ServiceAccount != grant.Workload.ServiceAccount || + reviewed.Namespace != grant.Workload.Namespace { + return nil, fmt.Errorf( + "%w: %s is declared to run as %s/%s, the presented token authenticates %s/%s", + ErrExecutionIdentityMismatch, principalID, + grant.Workload.Namespace, grant.Workload.ServiceAccount, + reviewed.Namespace, reviewed.ServiceAccount) + } + + // 2. Read the pod the TOKEN named — never one the caller named. + running, err := s.executionReviewer.RunningContainer( + ctx, reviewed.Namespace, reviewed.PodName, containerName) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrExecutionUnbound, err) + } + if !running.Found { + // A missing container is NOT a non-match: the host has established + // nothing about what is running, so it cannot say the caller is + // unapproved. + return nil, fmt.Errorf("%w: container %s is not in pod %s/%s", + ErrExecutionIdentityMismatch, containerName, reviewed.Namespace, reviewed.PodName) + } + if running.UID != reviewed.PodUID { + // The pod name resolved to a DIFFERENT pod than the token was issued + // for — a replacement at the same name. This is the check the UID + // exists for, and comparing names instead would admit it silently. + return nil, fmt.Errorf("%w: the token names pod uid %s, live pod %s/%s is uid %s", + ErrExecutionIdentityMismatch, reviewed.PodUID, + reviewed.Namespace, reviewed.PodName, running.UID) + } + if running.ImageID == "" { + // A container with no imageID is not yet running an identifiable image + // (still pulling, or a runtime that does not report one). Nothing has + // been established. + return nil, fmt.Errorf("%w: container %s in pod %s/%s reports no image id", + ErrExecutionUnbound, containerName, reviewed.Namespace, reviewed.PodName) + } + + // 3. Compare against what the authority approves. + approved, incarnation, err := s.executionAuthority.ApprovedBuild(ctx, principalID) + switch { + case errors.Is(err, ErrUnknownExecutionPrincipal): + return nil, fmt.Errorf("%w: %s", ErrUnknownExecutionPrincipal, principalID) + case errors.Is(err, ErrNoApprovedBuild): + // Known, and bears none: REFUSED. Execution binding is unconditional, + // so there is no such thing as a module capability carrying no + // execution. + // + // This branch used to return an identity with no running digest, for "a + // caller that may act unbound". Nothing could reach it — the only + // production ExecutionAuthority never calls Declare(), so the view + // answers a record or ErrUnknownExecutionPrincipal and never this. It + // was a fully built permissive path waiting for a trigger: adding the + // one Declare() call that the next legitimate requirement (a human + // session, say) obviously wants would have converted this refusal into + // a mintable capability, for a principal whose running build nothing + // checked, with no test failing. Deleting it is the point, not tidiness. + // + // The sentinel stays distinct from ErrUnknownExecutionPrincipal because + // the two are different operator problems — "declared but not yet + // approved" against "never heard of it" — and collapsing them would + // send the operator to the wrong place. Both refuse. + return nil, fmt.Errorf("%w: %s", ErrNoApprovedBuild, principalID) + case err != nil: + return nil, fmt.Errorf("%w: %w", ErrExecutionUnbound, err) + } + + if !digestsEqual(string(approved), running.ImageID) { + // The declared image is named in the refusal precisely because the + // interesting case is when it MATCHES the approval and the running image + // does not: that is a moved tag, and an operator reading only "not + // approved" would go looking at the authority document instead of the + // registry. + return nil, fmt.Errorf( + "%w: principal %s is approved for %s, pod %s/%s asked for %s and is running %s", + ErrExecutionNotApproved, principalID, approved, + reviewed.Namespace, reviewed.PodName, running.DeclaredImage, running.ImageID) + } + + return &ExecutionIdentity{ + PrincipalID: principalID, + Namespace: reviewed.Namespace, + ServiceAccount: reviewed.ServiceAccount, + PodName: reviewed.PodName, + PodUID: reviewed.PodUID, + ContainerName: containerName, + Running: RunningDigest(running.ImageID), + Declared: DeclaredDigest(running.DeclaredImage), + Incarnation: incarnation, + }, nil +} + +// digestsEqual compares an approved digest with a running imageID. +// +// A container's `imageID` is a REFERENCE carrying a digest, not a bare digest: +// Kubernetes reports forms like `registry/repo@sha256:…`, and some runtimes +// report `docker-pullable://registry/repo@sha256:…`. The approved digest may be +// written either as a bare `sha256:…` or as a full reference. +// +// So the comparison is on the digest PORTION, and it is an exact match on that +// portion rather than a suffix test. A suffix test is the trap: `strings. +// HasSuffix(imageID, approved)` passes when the approved digest is a suffix of a +// longer hex string, and a bare `sha256:` prefix check would let any repository +// satisfy an approval granted for one image. +// +// A value with no digest at all never matches, because an approval is only ever +// a statement about immutable content — a tag is not one. +func digestsEqual(approved, imageID string) bool { + left, leftOK := digestPortion(approved) + right, rightOK := digestPortion(imageID) + return leftOK && rightOK && left == right +} + +// digestPortion extracts the `sha256:` portion of a reference. +// +// Reports false when there is none, rather than returning the whole string: a +// fallback to comparing whole strings would make two tags compare equal and so +// turn a tag into an approval. +func digestPortion(reference string) (string, bool) { + index := strings.LastIndex(reference, "@") + candidate := reference + if index >= 0 { + candidate = reference[index+1:] + } + algorithm, hex, found := strings.Cut(candidate, ":") + if !found || algorithm == "" || hex == "" { + return "", false + } + // Only lower-case hex, and only a length a real digest has. Anything else + // is not a digest, and accepting it would let an arbitrary string that + // merely contains a colon pass as content-addressed. + if len(hex) < 32 { + return "", false + } + for i := 0; i < len(hex); i++ { + c := hex[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return "", false + } + } + return algorithm + ":" + hex, true +} + +// SetExecutionBinding wires the independent sources. +// +// Leaving them unset means BindExecution answers ErrExecutionUnbound — never a +// mint that skips the check. A host that cannot establish what a caller is +// running must not issue a capability claiming it did. +func (s *Service) SetExecutionBinding(reviewer ExecutionReviewer, authority ExecutionAuthority) { + s.executionReviewer = reviewer + s.executionAuthority = authority +} diff --git a/module/services/accounts/code/pkg/business/module_execution_binding_test.go b/module/services/accounts/code/pkg/business/module_execution_binding_test.go new file mode 100644 index 000000000..ecedb94d9 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_execution_binding_test.go @@ -0,0 +1,622 @@ +//go:build pure + +package business + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Execution-bound minting, and every way the binding could be defeated. +// +// The digests here are realistic shapes rather than short strings, because the +// comparison's correctness depends on the shapes: a container's `imageID` is a +// digest-bearing REFERENCE, not a bare digest, and that is where a suffix test +// or a prefix check goes wrong. + +const ( + approvedRef = "registry.example.com/acme/worker@sha256:" + + "1111111111111111111111111111111111111111111111111111111111111111" + // The same digest as a bare value, which is the other legitimate spelling. + approvedBare = "sha256:" + + "1111111111111111111111111111111111111111111111111111111111111111" + // A different image, same registry and repository. + otherRef = "registry.example.com/acme/worker@sha256:" + + "2222222222222222222222222222222222222222222222222222222222222222" + // The approved digest as some runtimes report it. + pullableRef = "docker-pullable://registry.example.com/acme/worker@sha256:" + + "1111111111111111111111111111111111111111111111111111111111111111" +) + +type fakeExecutionReviewer struct { + reviewed *ReviewedExecutionToken + reviewErr error + running *RunningContainerStatus + runErr error + // askedPod records which pod was read, which is how "read the pod the TOKEN + // named" is distinguished from "read the pod the caller named". + askedPod string + askedNamespace string + askedContainer string + askedAudience string +} + +func (f *fakeExecutionReviewer) ReviewToken( + _ context.Context, _, audience string, +) (*ReviewedExecutionToken, error) { + f.askedAudience = audience + if f.reviewErr != nil { + return nil, f.reviewErr + } + return f.reviewed, nil +} + +func (f *fakeExecutionReviewer) RunningContainer( + _ context.Context, namespace, pod, container string, +) (*RunningContainerStatus, error) { + f.askedNamespace, f.askedPod, f.askedContainer = namespace, pod, container + if f.runErr != nil { + return nil, f.runErr + } + return f.running, nil +} + +type fakeExecutionAuthority struct { + digest ApprovedDigest + incarnation uint64 + err error +} + +func (f *fakeExecutionAuthority) ApprovedBuild( + _ context.Context, _ string, +) (ApprovedDigest, uint64, error) { + if f.err != nil { + return "", 0, f.err + } + return f.digest, f.incarnation, nil +} + +// boundService wires the two independent sources AND declares the principal's +// workload, because the declaration is now part of the check: BindExecution +// holds the reviewed service account against the one the principal declares, so +// a service with no declaration refuses every caller as unknown. +// +// The declared workload matches reviewedPod() deliberately, so the tests below +// that are about the IMAGE are not also about the identity. The ones about the +// identity change one of these on purpose. +func boundService(reviewer ExecutionReviewer, authority ExecutionAuthority) *Service { + return boundServiceFor(reviewer, authority, ModuleWorkload{ + ServiceAccount: "worker", Namespace: "acme-prod", Container: "worker", + }) +} + +func boundServiceFor( + reviewer ExecutionReviewer, authority ExecutionAuthority, workload ModuleWorkload, +) *Service { + service := &Service{store: noopControlPlaneStore{}} + service.SetExecutionBinding(reviewer, authority) + service.SetModulePrincipals(ModulePrincipalRegistry{ + "principal-1": ModulePrincipalGrant{Prefix: "worker", Tenant: "11111111-1111-4111-8111-111111111111", Workload: workload}, + }) + return service +} + +func reviewedPod() *ReviewedExecutionToken { + return &ReviewedExecutionToken{ + ServiceAccount: "worker", + Namespace: "acme-prod", + PodName: "worker-7c9f", + PodUID: "uid-alpha", + } +} + +// --------------------------------------------------------------------------- +// The happy path, and the independence of the three sources +// --------------------------------------------------------------------------- + +// A pod running the approved image binds, and the identity carries what was +// ESTABLISHED rather than what was presented. +func TestApprovedRunningImageBinds(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + + identity, err := service.BindExecution(context.Background(), "principal-1", "token") + require.NoError(t, err) + require.Equal(t, RunningDigest(approvedRef), identity.Running) + require.Equal(t, uint64(7), identity.Incarnation, + "the incarnation comes from the authority document, never from the pod") + require.Equal(t, "uid-alpha", identity.PodUID) + + // The pod read used the namespace and name the TOKEN named. A caller cannot + // point the host at a different pod, which is why the token comes first. + require.Equal(t, "acme-prod", reviewer.askedNamespace) + require.Equal(t, "worker-7c9f", reviewer.askedPod) + require.Equal(t, "accounts", reviewer.askedAudience, + "the token must have been minted for this host, not for the API server") +} + +// Approved written as a full reference and running reported as a pullable URI +// still bind: the comparison is on the digest portion, not on the spelling. +func TestDigestComparisonIsOnTheDigestNotTheSpelling(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: pullableRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedRef, incarnation: 1}) + + identity, err := service.BindExecution(context.Background(), "principal-1", "token") + require.NoError(t, err) + require.Equal(t, uint64(1), identity.Incarnation) +} + +// --------------------------------------------------------------------------- +// Refusals: a verdict about the caller +// --------------------------------------------------------------------------- + +// THE MOVED TAG, and the case that proves the three digest types are distinct by +// execution rather than by comment. +// +// The pod's SPEC names exactly the approved image. Its STATUS reports something +// else — which is what a moved tag produces, and what a mutable registry +// reference makes possible at any time. Comparing approved against DECLARED +// would pass here and admit an unapproved workload; comparing declared against +// running would prove only that the pod got what it asked for. +// +// Approved == RUNNING is the only comparison that refuses it. +func TestAPodThatAskedForTheApprovedImageAndRunsAnotherIsRefused(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{ + UID: "uid-alpha", + // Asked for the approved image... + DeclaredImage: approvedRef, + // ...and is running a different one. + ImageID: otherRef, + Found: true, + }, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionNotApproved) + // The refusal names BOTH, because an operator told only "not approved" would + // go reading the authority document when the registry is what moved. + require.Contains(t, err.Error(), approvedRef, "the refusal must name what was asked for") + require.Contains(t, err.Error(), otherRef, "and what is actually running") +} + +// And the control: the same spec with a RUNNING image that matches is admitted, +// so the test above is not passing because every declared image is refused. +func TestAPodRunningWhatItAskedForAndWhatIsApprovedIsAdmitted(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{ + UID: "uid-alpha", DeclaredImage: approvedRef, ImageID: approvedRef, Found: true, + }, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 4}) + + identity, err := service.BindExecution(context.Background(), "principal-1", "token") + require.NoError(t, err) + require.Equal(t, RunningDigest(approvedRef), identity.Running) + require.Equal(t, DeclaredDigest(approvedRef), identity.Declared, + "the declared image is carried for diagnosis, distinct from the running one") +} + +// A pod running a DIFFERENT image from the same repository is refused. Same +// registry, same repo, different digest — which is what a moved tag produces. +func TestUnapprovedRunningImageIsRefused(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: otherRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionNotApproved) + require.NotErrorIs(t, err, ErrExecutionUnbound, + "this is a verdict about the caller, not a failure to establish one") +} + +// A replacement pod at the SAME NAME is refused, and this is the check the UID +// exists for. A name comparison would admit it silently. +func TestReplacementPodAtTheSameNameIsRefused(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + // Same name, running the approved image, DIFFERENT uid. + running: &RunningContainerStatus{UID: "uid-beta", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 1}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionIdentityMismatch) + require.Contains(t, err.Error(), "uid-alpha") + require.Contains(t, err.Error(), "uid-beta") +} + +// A legacy non-bound token is REFUSED, not skipped. +// +// Skipping would make the whole check opt-out by presenting an older token, +// which is the easiest possible bypass of an execution binding. +func TestNonBoundTokenIsRefusedRatherThanSkipped(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: &ReviewedExecutionToken{ServiceAccount: "worker", Namespace: "acme-prod"}, + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionNotBoundToken) + require.Empty(t, reviewer.askedPod, "no pod is read for a token that names none") +} + +// An unknown principal is refused outright rather than minting an unbound +// capability — which would be a capability for an identity the host has never +// heard of. +func TestUnknownPrincipalIsRefusedNotMintedUnbound(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{err: ErrUnknownExecutionPrincipal}) + + _, err := service.BindExecution(context.Background(), "stranger", "token") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) +} + +// --------------------------------------------------------------------------- +// Unbound: the host could not tell, which is NOT a verdict +// --------------------------------------------------------------------------- + +// Nothing is issued when the API is unavailable, and the answer is distinct from +// a refusal. +// +// That distinction is the whole point: an unreachable control plane means the +// host cannot establish what the caller is running, and a mint that proceeded +// would issue an unbound capability exactly when binding was impossible. +func TestNothingIsIssuedWhenTheApiIsUnavailable(t *testing.T) { + unreachable := errors.New("dial tcp: connection refused") + + // Unreachable at the TokenReview. + service := boundService( + &fakeExecutionReviewer{reviewErr: unreachable}, + &fakeExecutionAuthority{digest: approvedBare}) + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionUnbound) + require.NotErrorIs(t, err, ErrExecutionNotApproved) + + // Unreachable at the pod read, after a successful review. + service = boundService( + &fakeExecutionReviewer{reviewed: reviewedPod(), runErr: unreachable}, + &fakeExecutionAuthority{digest: approvedBare}) + _, err = service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionUnbound) + + // Unreadable authority document. + service = boundService( + &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + }, + &fakeExecutionAuthority{err: unreachable}) + _, err = service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionUnbound) + require.NotErrorIs(t, err, ErrExecutionNotApproved) +} + +// A host with no reviewer wired cannot establish anything and says so, rather +// than minting a capability that claims it did. +func TestUnwiredHostCannotBindExecution(t *testing.T) { + service := &Service{store: noopControlPlaneStore{}} + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionUnbound) +} + +// A container still pulling reports no imageID: nothing has been established, so +// it is unbound rather than unapproved. +func TestContainerWithNoImageIdIsUnboundNotUnapproved(t *testing.T) { + service := boundService( + &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: "", Found: true}, + }, + &fakeExecutionAuthority{digest: approvedBare}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionUnbound) + require.NotErrorIs(t, err, ErrExecutionNotApproved) +} + +// A missing container is a mismatch, not a non-match: the host established +// nothing about what is running. +func TestMissingContainerIsAMismatchNotANonMatch(t *testing.T) { + service := boundService( + &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", Found: false}, + }, + &fakeExecutionAuthority{digest: approvedBare}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionIdentityMismatch) + require.NotErrorIs(t, err, ErrExecutionNotApproved) +} + +// No container named is refused rather than defaulting to the pod's first, and +// the property survived a change of premise. +// +// It used to assert that a caller passing an EMPTY container argument was +// refused. There is no such argument any more: the container is read from the +// declaration, which is strictly stronger — the caller cannot name one at all, +// so it cannot name a sibling either. The defaulting it guarded against is +// still the tempting mistake, because a pod's first container would satisfy the +// check for the workload beside it, so the property is kept and re-aimed at the +// declaration. +// +// The "no container declared" half is TestAPartiallyDeclaredWorkloadAuthenticatesNobody; +// this is the half that says nothing is read when the identity cannot be +// established, so a pod is never touched on the strength of a partial +// declaration. +func TestNoContainerDeclaredReadsNoPodAtAll(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundServiceFor(reviewer, &fakeExecutionAuthority{digest: approvedBare}, + ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod"}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) + require.Empty(t, reviewer.askedPod, + "a principal whose workload is not fully declared must not cause a pod read at all") +} + +// --------------------------------------------------------------------------- +// Bearing no approved build: the third state +// --------------------------------------------------------------------------- + +// A known principal bearing no approved build binds WITHOUT an execution, and +// the pair stays whole-or-absent. +// +// Both fields absent together is what core's optional-and-paired seal requires: +// a digest with no incarnation, or an incarnation with no digest, is refused by +// its schema. +// A principal the host knows but has approved no build for is REFUSED, and is +// told apart from a principal the host has never heard of. +// +// This test asserted the opposite until the branch was deleted: it required +// NoError and an empty Running digest, locking in "a capability may still be +// minted, carrying NO execution". Nothing in production could reach that answer +// — the only ExecutionAuthority never calls Declare() — so the permissive path +// sat fully built and unit-tested, one Declare() call away from being live. The +// test is ADAPTED rather than deleted, because the input it drives is still a +// real answer from the authority and the question "what happens to it" still +// needs one. +func TestPrincipalBearingNoApprovedBuildIsRefusedNotMintedUnbound(t *testing.T) { + service := boundService( + &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + }, + &fakeExecutionAuthority{err: ErrNoApprovedBuild}) + + identity, err := service.BindExecution(context.Background(), "principal-1", "token") + require.Error(t, err, "execution binding is unconditional: there is nothing to mint") + require.Nil(t, identity, "no identity is returned, so none can be sealed") + require.ErrorIs(t, err, ErrNoApprovedBuild, + "and NOT ErrUnknownExecutionPrincipal: declared-but-unapproved and never-heard-of are "+ + "different operator problems, so the sentinels stay separate even though both refuse") + require.NotErrorIs(t, err, ErrUnknownExecutionPrincipal) +} + +// THE INVARIANT, over every answer the authority can give: if BindExecution +// returns an identity at all, that identity carries a running digest. +// +// Stated as a property rather than as another example, because the failure this +// guards is someone re-introducing a "may act unbound" path — and a path like +// that arrives with its own passing example test. What it cannot do is satisfy +// this: a mintable identity with an empty Running digest fails here whatever +// route produced it. +// +// Mutation-verified: making the ErrNoApprovedBuild case return +// &ExecutionIdentity{...} with no Running digest (the code exactly as it was +// before this fix) fails this test on that row, and returning one with a +// Running digest copied from the pod's status fails the approved-digest +// comparison instead. Removing the require.NotEmpty makes the whole test pass +// against the old permissive branch, which is how I know the assertion is the +// load-bearing line and not the table. +func TestEveryBoundExecutionCarriesARunningDigest(t *testing.T) { + for _, answer := range []struct { + name string + authority *fakeExecutionAuthority + }{ + {"approved", &fakeExecutionAuthority{digest: approvedBare, incarnation: 3}}, + {"known, no approved build", &fakeExecutionAuthority{err: ErrNoApprovedBuild}}, + {"unknown principal", &fakeExecutionAuthority{err: ErrUnknownExecutionPrincipal}}, + {"authority unreadable", &fakeExecutionAuthority{err: errors.New("authority is unreadable")}}, + } { + t.Run(answer.name, func(t *testing.T) { + service := boundService( + &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + }, + answer.authority) + + identity, err := service.BindExecution(context.Background(), "principal-1", "token") + if err != nil { + require.Nil(t, identity, + "a refusal returns no identity, or a caller could seal the one it was handed anyway") + return + } + require.NotEmpty(t, identity.Running, + "a minted execution identity with no running digest is a capability no verifier can "+ + "hold against a running image: execution binding is unconditional") + }) + } +} + +// --------------------------------------------------------------------------- +// The digest comparison itself, where the subtle defeats live +// --------------------------------------------------------------------------- + +// A tag is never an approval, however it is spelled. +// +// An approval is a statement about immutable content, and a tag is not one, so a +// value with no digest must never match — including when both sides carry the +// same tag, which a whole-string fallback would accept. +func TestATagNeverMatches(t *testing.T) { + for _, pair := range []struct{ approved, running string }{ + {"registry.example.com/acme/worker:v1", "registry.example.com/acme/worker:v1"}, + {approvedBare, "registry.example.com/acme/worker:v1"}, + {"registry.example.com/acme/worker:v1", approvedRef}, + {"", approvedRef}, + {approvedBare, ""}, + } { + require.False(t, digestsEqual(pair.approved, pair.running), + "approved %q must not match running %q", pair.approved, pair.running) + } +} + +// A suffix is not a match, which is the specific trap `strings.HasSuffix` sets: +// an approved digest that is a suffix of a longer hex string would pass. +func TestASuffixIsNotAMatch(t *testing.T) { + longer := "sha256:ff" + strings.TrimPrefix(approvedBare, "sha256:") + require.False(t, digestsEqual(approvedBare, "registry.example.com/acme/worker@"+longer), + "a digest that merely ENDS WITH the approved one is a different image") +} + +// A shared prefix is not a match either: checking only that both start with +// `sha256:` would let any repository satisfy an approval granted for one image. +func TestASharedAlgorithmPrefixIsNotAMatch(t *testing.T) { + require.False(t, digestsEqual(approvedBare, otherRef)) +} + +// A non-hex or too-short value is not a digest, so it never matches — otherwise +// any string containing a colon would pass as content-addressed. +func TestANonDigestIsNotADigest(t *testing.T) { + for _, value := range []string{"sha256:short", "sha256:" + strings.Repeat("z", 64), "notadigest", "sha256:"} { + _, ok := digestPortion(value) + require.False(t, ok, "%q must not read as a digest", value) + } + // And the control: the real shapes DO read as digests, or the test above + // would pass against a function that rejects everything. + for _, value := range []string{approvedBare, approvedRef, pullableRef} { + _, ok := digestPortion(value) + require.True(t, ok, "%q must read as a digest", value) + } +} + +// --------------------------------------------------------------------------- +// The principal is a CLAIM, and the declared workload is what checks it +// --------------------------------------------------------------------------- + +// THE HOLE THIS CLOSES, which only opened when the shared secret went away. +// BindExecution takes the principal as an input, and the secret used to be what +// bound that prefix to the caller. With execution binding as the only +// authentication, a module holding its own perfectly valid service-account token +// could name ANOTHER module's prefix — and the image comparison would then test +// that other principal's approved build against this caller's running image, +// which passes whenever the two share an image. A shared base image is ordinary. +func TestACallerCannotNameAnotherPrincipalsPrefix(t *testing.T) { + // The caller is genuinely `intruder` in the same namespace, and is running + // the image `principal-1` is approved for — which is exactly the case that + // passes if the service account is not checked. + reviewer := &fakeExecutionReviewer{ + reviewed: &ReviewedExecutionToken{ + ServiceAccount: "intruder", Namespace: "acme-prod", + PodName: "intruder-1", PodUID: "uid-intruder", + }, + running: &RunningContainerStatus{UID: "uid-intruder", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionIdentityMismatch, + "a caller running the approved image must still be refused when it is not the declared workload") + require.Contains(t, err.Error(), "intruder", + "the refusal must name who the caller actually is, or an operator cannot act on it") +} + +// The same check on the namespace half, separately, so one of the two passing +// cannot carry the other. +func TestACallerInAnotherNamespaceIsRefused(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: &ReviewedExecutionToken{ + ServiceAccount: "worker", Namespace: "acme-staging", + PodName: "worker-7c9f", PodUID: "uid-alpha", + }, + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrExecutionIdentityMismatch) + require.Contains(t, err.Error(), "acme-staging") +} + +// A principal this host has no declaration for is UNKNOWN, not unapproved. +// Collapsing those is the dangerous direction: "bears no approved build" mints a +// capability carrying no execution, which for an identity the host has never +// heard of is a credential nothing can revoke. +func TestAnUndeclaredPrincipalIsUnknownRatherThanUnapproved(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundService(reviewer, &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}) + + _, err := service.BindExecution(context.Background(), "principal-unheard-of", "token") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) + require.NotErrorIs(t, err, ErrNoApprovedBuild, + "an unknown principal must not read as one that bears no build, which would mint an unbound capability") +} + +// A declaration missing any part of its workload is refused too, rather than +// checking the parts it has. A partial workload is the whole-or-absent trap: it +// reads as enforcement while leaving a term unchecked. +func TestAPartiallyDeclaredWorkloadAuthenticatesNobody(t *testing.T) { + for name, workload := range map[string]ModuleWorkload{ + "no service account": {Namespace: "acme-prod", Container: "worker"}, + "no namespace": {ServiceAccount: "worker", Container: "worker"}, + "no container": {ServiceAccount: "worker", Namespace: "acme-prod"}, + } { + t.Run(name, func(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundServiceFor(reviewer, + &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}, workload) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.ErrorIs(t, err, ErrUnknownExecutionPrincipal) + }) + } +} + +// THE CONTAINER COMES FROM THE DECLARATION, which is the last degree of freedom +// the caller had over its own identity: a pod's containers do not all run the +// same image, so naming a sibling picks which image the approval is tested +// against. +func TestTheContainerReadIsTheDeclaredOneNotTheCallersChoice(t *testing.T) { + reviewer := &fakeExecutionReviewer{ + reviewed: reviewedPod(), + running: &RunningContainerStatus{UID: "uid-alpha", ImageID: approvedRef, Found: true}, + } + service := boundServiceFor(reviewer, + &fakeExecutionAuthority{digest: approvedBare, incarnation: 7}, + ModuleWorkload{ServiceAccount: "worker", Namespace: "acme-prod", Container: "declared-app"}) + + _, err := service.BindExecution(context.Background(), "principal-1", "token") + require.NoError(t, err) + require.Equal(t, "declared-app", reviewer.askedContainer, + "the pod read must ask for the DECLARED container; reading any other picks which image the approval is tested against") +} diff --git a/module/services/accounts/code/pkg/business/module_installation_admission.go b/module/services/accounts/code/pkg/business/module_installation_admission.go new file mode 100644 index 000000000..3e01348ce --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_installation_admission.go @@ -0,0 +1,232 @@ +package business + +import ( + "context" + "errors" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// A module capability NAMES the installation it acts under, and this is where +// the host decides whether it may. +// +// THE MODEL, and why the alternative had to be refused. Core's mint requires an +// installation id — "a capability is sealed to an installation, so one must be +// named", because authority is held THROUGH an installation and a capability +// naming none is bound to nothing. The host had nothing to pass: a module +// principal's declared grant carries a prefix, queues, namespaces, resources and +// audiences, and nothing installation-shaped, because an installation is an +// organisation's consent to a solution TARGET and the module principal is not a +// party to it. +// +// The resolution is not to invent one. A synthetic installation, or the first +// active installation in the tenant, would fill the seal from the host's own view +// of state — and then every downstream check compares a value to itself, which is +// the tautology that passes for every caller. So the REQUEST carries it: +// +// - a viewer-driven call names the viewer's installation of the solution; +// - a module-to-module call carries forward the installation its own +// capability was sealed to, so authority narrows along the chain and never +// hops to another organisation's consent. +// +// `ModulePrincipalGrant` stays a CEILING rather than becoming a directory. It +// says what this module may ever do; it does not say which installations exist, +// and a registry that answered that question would be a second source of truth +// for consent. +// +// WHAT MAKES THIS THE INSTALLATION-FRESHNESS TERM MADE REAL: once the seal names +// an installation, uninstalling revokes every capability sealed to it on its next +// use, because the live re-read resolves that exact installation and finds it +// inactive. Before this, nothing related the two and the term was unanswerable. + +// ErrModuleInstallationNotNamed reports a capability request that names no +// installation. +// +// Refused rather than defaulted. A default would be the host choosing whose +// consent the module acts under, which is the one decision the request exists to +// carry. +var ErrModuleInstallationNotNamed = errors.New("a module capability must name the installation it acts under") + +// ErrModuleNotComposedBySolution reports an installation whose solution never +// composed this module. +// +// This is the authorisation step people expect the registry to do and it cannot: +// the declared registry is a ceiling over what a module may do anywhere, while +// THIS says the organisation's consent covers this module at all. A module may be +// perfectly well declared and still have no business acting under a given +// installation. +var ErrModuleNotComposedBySolution = errors.New("the installed solution does not compose this module") + +// ModuleInstallationAdmission is what the host established about a named +// installation, and what it will seal. +type ModuleInstallationAdmission struct { + InstallationID string + InstallationRevision uint64 + ProducerEpoch uint64 + // TargetID and BindingID are the solution target the installation consents + // to and the presence binding that target records. Carried because the + // refusal messages need them: "this module is not composed by that solution" + // is only actionable if the operator can see which solution. + TargetID string + BindingID string +} + +// ModuleInstallationResolver resolves a named installation to the solution that +// consented to it, and to the modules that solution composed. +// +// One method, and one snapshot. Resolving the installation, then its target, +// then the target's applied document in three reads lets an uninstall or a new +// generation land between them and produce an admission describing a state that +// never existed. +type ModuleInstallationResolver interface { + // ResolveModuleInstallation reads the installation, its live target, and the + // module names the target's APPLIED presence document pins. + // + // The applied document rather than the desired one: a module may act under + // what the host actually reconciled, never under what delivery merely + // proposed. Returns ErrModuleInstallationInactive when the installation is + // revoked, absent, or another organisation's. + ResolveModuleInstallation(ctx context.Context, orgID, installationID string) (*ResolvedModuleInstallation, error) +} + +// ResolvedModuleInstallation is one installation with the solution behind it. +type ResolvedModuleInstallation struct { + InstallationID string + InstallationRevision uint64 + TargetID string + BindingID string + // ComposedModules are the module names the applied presence document pins. + // Empty is meaningful: a solution that composed nothing admits no module, + // and treating empty as "any" would make an undeclared solution the most + // permissive one. + ComposedModules []string +} + +// AdmitModuleUnderInstallation decides whether this module may act under the +// installation its request names. +// +// The order is cheapest-and-most-terminal first, so a caller reads the real +// cause: an unnamed installation is refused before any database work; an unknown +// principal before the installation is resolved; and composition is checked +// before the epoch, because "your solution does not include this module" is a +// permanent answer while a stale epoch is a re-mint. +func (s *Service) AdmitModuleUnderInstallation( + ctx context.Context, caller ModuleCaller, installationID string, +) (*ModuleInstallationAdmission, error) { + if installationID == "" { + return nil, wrapStatus(codes.InvalidArgument, ErrModuleInstallationNotNamed, + "principal %s", caller.PrincipalID) + } + grant, err := s.moduleGrant(caller) + if err != nil { + return nil, err + } + if s.moduleInstallations == nil { + return nil, status.Error(codes.FailedPrecondition, + "this host cannot resolve a module installation, so no module capability may be minted") + } + + resolved, err := s.moduleInstallations.ResolveModuleInstallation(ctx, caller.BoundOrg, installationID) + if err != nil { + if errors.Is(err, ErrModuleInstallationInactive) { + return nil, wrapStatus(codes.PermissionDenied, ErrModuleInstallationInactive, + "installation %s in organization %s", installationID, caller.BoundOrg) + } + return nil, status.Errorf(codes.Unavailable, "cannot resolve module installation: %v", err) + } + + // The solution must actually compose this module. The module's declared + // prefix is the name a composition pins it under, which is what makes this + // comparable at all. + if !composesModule(resolved.ComposedModules, grant.Prefix) { + return nil, wrapStatus(codes.PermissionDenied, ErrModuleNotComposedBySolution, + "module %q, installation %s, solution binding %s", + grant.Prefix, installationID, resolved.BindingID) + } + + live, err := s.liveAuthorityFor(ctx, caller, installationID) + if err != nil { + return nil, err + } + + return &ModuleInstallationAdmission{ + InstallationID: resolved.InstallationID, + InstallationRevision: resolved.InstallationRevision, + ProducerEpoch: live.ProducerEpoch, + TargetID: resolved.TargetID, + BindingID: resolved.BindingID, + }, nil +} + +// liveAuthorityFor reads the producer epoch for this caller under this +// installation. +// +// Separate from the resolve so the two refusals stay attributable: "your +// solution does not compose this module" and "your authority has moved" are +// different facts with different remedies. +func (s *Service) liveAuthorityFor( + ctx context.Context, caller ModuleCaller, installationID string, +) (*LiveModuleAuthority, error) { + if s.moduleAuthority == nil { + return nil, status.Error(codes.FailedPrecondition, + "this host cannot re-read live module authority, so no capability may be minted") + } + live, err := s.moduleAuthority.LiveModuleAuthority( + ctx, caller.PrincipalID, caller.BoundOrg, installationID) + if err != nil { + if errors.Is(err, ErrModuleInstallationInactive) { + return nil, wrapStatus(codes.PermissionDenied, ErrModuleInstallationInactive, + "installation %s", installationID) + } + if errors.Is(err, ErrModuleAuthorityUnreadable) { + return nil, wrapStatus(codes.InvalidArgument, ErrModuleAuthorityUnreadable, + "principal %s", caller.PrincipalID) + } + return nil, status.Errorf(codes.Unavailable, "cannot re-read live module authority: %v", err) + } + return live, nil +} + +// composesModule reports whether a solution's pinned modules include this one. +// +// An exact match on the module name. Not a prefix or substring test: `crm` must +// not be admitted by a solution composing `crm-reporting`, and a substring check +// is the kind of thing that reads as lenient and is actually a privilege +// escalation between two real modules. +func composesModule(composed []string, prefix string) bool { + if prefix == "" { + // A module with no declared prefix cannot be matched against anything, + // and admitting it would mean admitting every solution. + return false + } + for _, module := range composed { + if module == prefix { + return true + } + } + return false +} + +// SealFor renders what core's mint needs from an admission. +// +// Returned as the pointer-valued SealedModuleAuthority the verify side already +// compares, so the mint and the check cannot disagree about which terms are +// present: the same type carries them in both directions. +func (a *ModuleInstallationAdmission) SealFor() *SealedModuleAuthority { + installation, revision, epoch := a.InstallationID, a.InstallationRevision, a.ProducerEpoch + return &SealedModuleAuthority{ + InstallationID: &installation, + InstallationRevision: &revision, + ProducerEpoch: &epoch, + } +} + +// SetModuleInstallations wires the installation resolution. +// +// Unset means AdmitModuleUnderInstallation refuses, which is fail-closed: a host +// that cannot establish whose consent a module acts under must not mint a +// capability claiming it did. +func (s *Service) SetModuleInstallations(store ModuleInstallationResolver) { + s.moduleInstallations = store +} diff --git a/module/services/accounts/code/pkg/business/module_installation_admission_test.go b/module/services/accounts/code/pkg/business/module_installation_admission_test.go new file mode 100644 index 000000000..b5d76c6d5 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_installation_admission_test.go @@ -0,0 +1,195 @@ +//go:build pure + +package business + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// A module capability names the installation it acts under, and every way that +// naming can be wrong. + +type fakeInstallationResolver struct { + resolved *ResolvedModuleInstallation + err error + // askedOrg and askedInstallation record what was resolved, so "the request's + // installation reached the read" is observable rather than assumed — the + // whole point is that the host does not pick one. + askedOrg string + askedInstallation string +} + +func (f *fakeInstallationResolver) ResolveModuleInstallation( + _ context.Context, orgID, installationID string, +) (*ResolvedModuleInstallation, error) { + f.askedOrg, f.askedInstallation = orgID, installationID + if f.err != nil { + return nil, f.err + } + return f.resolved, nil +} + +func admittingService( + resolver ModuleInstallationResolver, authority ModuleAuthorityStore, +) *Service { + service := &Service{store: noopControlPlaneStore{}} + service.SetModulePrincipals(ModulePrincipalRegistry{ + "principal-1": {Prefix: "crm", Tenant: "org-1", Queues: []string{"a"}}, + }) + service.SetModuleInstallations(resolver) + service.SetModuleAuthorityReads(authority, nil) + return service +} + +func composedBy(modules ...string) *fakeInstallationResolver { + return &fakeInstallationResolver{resolved: &ResolvedModuleInstallation{ + InstallationID: "install-1", + InstallationRevision: 7, + TargetID: "target-1", + BindingID: "acme.test.crm-suite", + ComposedModules: modules, + }} +} + +// The happy path: the request names an installation, the solution composes this +// module, and the admission carries what will be sealed. +func TestAdmissionSealsTheNamedInstallation(t *testing.T) { + resolver := composedBy("billing", "crm") + service := admittingService(resolver, liveAt(7, 3)) + + admission, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.NoError(t, err) + require.Equal(t, "install-1", admission.InstallationID) + require.Equal(t, uint64(7), admission.InstallationRevision) + require.Equal(t, uint64(3), admission.ProducerEpoch) + + // The REQUEST's installation reached the read. The host must not pick one. + require.Equal(t, "install-1", resolver.askedInstallation) + require.Equal(t, "org-1", resolver.askedOrg) + + // And the seal carries all three terms, so the verify side has something to + // compare — which is the entire point of the contract. + sealed := admission.SealFor() + require.NotNil(t, sealed.InstallationID) + require.NotNil(t, sealed.InstallationRevision) + require.NotNil(t, sealed.ProducerEpoch) + require.Equal(t, "install-1", *sealed.InstallationID) +} + +// Naming NO installation is refused before any database work. +// +// A default would be the host choosing whose consent the module acts under, +// which is the one decision the request exists to carry. +func TestAdmissionRefusesARequestNamingNoInstallation(t *testing.T) { + resolver := composedBy("crm") + service := admittingService(resolver, liveAt(7, 3)) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "") + require.ErrorIs(t, err, ErrModuleInstallationNotNamed) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + require.Empty(t, resolver.askedInstallation, "an unnamed installation must cost no database work") +} + +// THE AUTHORISATION STEP THE REGISTRY CANNOT DO. A module may be perfectly well +// declared and still have no business acting under a given installation: the +// declared registry is a ceiling over what it may do ANYWHERE, while this says +// the organisation's consent covers this module at all. +func TestAdmissionRefusesAnInstallationWhoseSolutionDoesNotComposeThisModule(t *testing.T) { + resolver := composedBy("billing", "inventory") + service := admittingService(resolver, liveAt(7, 3)) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.ErrorIs(t, err, ErrModuleNotComposedBySolution) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + // The refusal names the solution binding, or an operator cannot act on it. + require.Contains(t, err.Error(), "acme.test.crm-suite") +} + +// A solution composing NOTHING admits no module. Empty must not read as "any", +// which would make an undeclared solution the most permissive one. +func TestAdmissionRefusesASolutionComposingNothing(t *testing.T) { + service := admittingService(composedBy(), liveAt(7, 3)) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.ErrorIs(t, err, ErrModuleNotComposedBySolution) +} + +// Composition is matched EXACTLY. `crm` must not be admitted by a solution +// composing `crm-reporting`: a substring test reads as lenient and is a +// privilege escalation between two real modules. +func TestAdmissionMatchesTheModuleNameExactly(t *testing.T) { + for _, composed := range []string{"crm-reporting", "acme-crm", "CRM", "crm "} { + service := admittingService(composedBy(composed), liveAt(7, 3)) + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.ErrorIs(t, err, ErrModuleNotComposedBySolution, + "a solution composing %q must not admit module crm", composed) + } +} + +// A revoked, absent or foreign installation is refused — the installation +// freshness term, now answerable because the request names one. +func TestAdmissionRefusesADeadOrForeignInstallation(t *testing.T) { + service := admittingService( + &fakeInstallationResolver{err: ErrModuleInstallationInactive}, liveAt(7, 3)) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-gone") + require.ErrorIs(t, err, ErrModuleInstallationInactive) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +// An unresolvable installation is Unavailable, not a denial: the host does not +// know whether the consent exists, and a blip must not read as revocation. +func TestAdmissionIsUndecidableWhenTheResolveFails(t *testing.T) { + service := admittingService( + &fakeInstallationResolver{err: errors.New("connection reset")}, liveAt(7, 3)) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.Equal(t, codes.Unavailable, status.Code(err)) + require.NotErrorIs(t, err, ErrModuleInstallationInactive) +} + +// An unknown principal is refused before the installation is resolved: the +// registry is the allowlist, and an unauthenticated caller must not make this +// host do database work. +func TestAdmissionRefusesAnUnknownPrincipalBeforeResolving(t *testing.T) { + resolver := composedBy("crm") + service := admittingService(resolver, liveAt(7, 3)) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), + ModuleCaller{PrincipalID: "stranger", BoundOrg: "org-1"}, "install-1") + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Empty(t, resolver.askedInstallation) +} + +// A host with no resolver wired refuses, rather than minting a capability whose +// installation nothing established. +func TestAdmissionRefusesWithoutAResolverWired(t *testing.T) { + service := &Service{store: noopControlPlaneStore{}} + service.SetModulePrincipals(ModulePrincipalRegistry{ + "principal-1": {Prefix: "crm", Tenant: "org-1"}, + }) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.Equal(t, codes.FailedPrecondition, status.Code(err)) +} + +// Composition is checked BEFORE the epoch, because the two refusals have +// different remedies: "your solution does not include this module" is permanent, +// while a stale epoch is a re-mint. A caller that reads the wrong one retries +// forever or gives up wrongly. +func TestAdmissionReportsCompositionBeforeAStaleEpoch(t *testing.T) { + // Not composed AND the authority read would fail: the composition refusal + // must win. + service := admittingService(composedBy("billing"), + &fakeModuleAuthority{err: errors.New("connection reset")}) + + _, err := service.AdmitModuleUnderInstallation(context.Background(), caller(), "install-1") + require.ErrorIs(t, err, ErrModuleNotComposedBySolution) + require.NotEqual(t, codes.Unavailable, status.Code(err)) +} diff --git a/module/services/accounts/code/pkg/business/module_installation_authority.go b/module/services/accounts/code/pkg/business/module_installation_authority.go index ad3dde1a4..10f40e78f 100644 --- a/module/services/accounts/code/pkg/business/module_installation_authority.go +++ b/module/services/accounts/code/pkg/business/module_installation_authority.go @@ -13,7 +13,7 @@ import ( // ModuleInstallationAuthorityVersion identifies the installation contract being // fingerprinted, not a software release, credential or live authorization token. -const ModuleInstallationAuthorityVersion = "accounts.module-installation-authority/v1" +const ModuleInstallationAuthorityVersion = "accounts.module-installation-authority/v2" var ErrInstallationAuthorityVersion = errors.New("unsupported installation authority reference version") @@ -34,10 +34,10 @@ func moduleInstallationAuthority(caller ModuleCaller, delegation InstallerDelega // Only Accounts chooses these durable identities. Display labels, installer // credential/expiry, software/image versions and deployment receipts are absent. payload := map[string]any{ - "schema_version": ModuleInstallationAuthorityVersion, - "module_id": req.ModuleID, - "agent_identifier": req.AgentIdentifier, - "solution_identifier": req.SolutionIdentifier, + "schema_version": ModuleInstallationAuthorityVersion, + "module_id": req.ModuleID, + "agent_identifier": req.AgentIdentifier, + "target_id": req.TargetID, } for key, value := range map[string]string{ "organization_id": result.OrganizationID, diff --git a/module/services/accounts/code/pkg/business/module_installation_authority_test.go b/module/services/accounts/code/pkg/business/module_installation_authority_test.go index 91c41c103..57af65dc3 100644 --- a/module/services/accounts/code/pkg/business/module_installation_authority_test.go +++ b/module/services/accounts/code/pkg/business/module_installation_authority_test.go @@ -35,6 +35,13 @@ func (s *authorityInstallationStore) WithOrgTx(ctx context.Context, _ string, fn func (s *authorityInstallationStore) GetOrganizationBySlug(context.Context, string) (*gen.Organization, error) { return &gen.Organization{Id: s.result.OrganizationID}, nil } + +// The audience vocabulary's one read. This store declares NO solution bindings: +// the ceiling these tests install names declared MODULE prefixes, which the +// fixture declares through the principal registry below. +func (s *authorityInstallationStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + return nil, nil +} func (s *authorityInstallationStore) ReconcileModuleInstallation(context.Context, *business.InstallSolutionParams, []string, bool) (*business.ModuleInstallationResult, error) { s.calls++ result := s.result @@ -44,10 +51,10 @@ func authorityInstallationFixture(t *testing.T, module string) (*business.Servic t.Helper() d := business.InstallerDelegation{ Prefix: "example-installer", ModuleID: "acme.example/" + module, - OrganizationID: "11111111-1111-4111-8111-111111111111", - OwnerPrincipalID: "22222222-2222-4222-8222-222222222222", - RoleID: "33333333-3333-4333-8333-333333333333", - SolutionIdentifier: "example-solution", AgentIdentifiers: []string{"acme.example/" + module + ":1.0.0"}, + OrganizationID: "11111111-1111-4111-8111-111111111111", + OwnerPrincipalID: "22222222-2222-4222-8222-222222222222", + RoleID: "33333333-3333-4333-8333-333333333333", + TargetID: "44444444-4444-4444-8444-444444444444", AgentIdentifiers: []string{"acme.example/" + module + ":1.0.0"}, RolePermissions: []string{"example:read", "example:write"}, AllowedAudiences: []string{"example.api", "example.worker"}, AllowedScopes: []string{"example", "example-subset"}, ExpiresAt: time.Now().Add(time.Hour), @@ -55,7 +62,7 @@ func authorityInstallationFixture(t *testing.T, module string) (*business.Servic caller := business.ModuleCaller{PrincipalID: business.ModulePrincipalID(d.Prefix), BoundOrg: d.OrganizationID} req := business.ModuleInstallationRequest{ AuthorityReferenceVersion: business.ModuleInstallationAuthorityVersion, - ModuleID: d.ModuleID, OrganizationSlug: "example-org", AgentIdentifier: d.AgentIdentifiers[0], SolutionIdentifier: d.SolutionIdentifier, + ModuleID: d.ModuleID, OrganizationSlug: "example-org", AgentIdentifier: d.AgentIdentifiers[0], TargetID: d.TargetID, RoleID: d.RoleID, ExpectedRolePermissions: slices.Clone(d.RolePermissions), AllowedAudiences: slices.Clone(d.AllowedAudiences), AllowedScopes: slices.Clone(d.AllowedScopes), DisplayName: "Example", RootScopeLabel: "Example", } @@ -65,8 +72,53 @@ func authorityInstallationFixture(t *testing.T, module string) (*business.Servic }} service, err := business.NewService(store) require.NoError(t, err) + // The ceiling may only name audiences this host SERVES (issue #952), and the + // installer's path validates it like every other write path. These two are + // declared module prefixes, declared the way a composition declares them. + service.SetModulePrincipals(business.ModulePrincipalRegistry{ + business.ModulePrincipalID("example.api"): {Prefix: "example.api"}, + business.ModulePrincipalID("example.worker"): {Prefix: "example.worker"}, + }) return service, store, caller, &business.InstallerPolicy{Version: "accounts.module-installation-policy/v1", Delegations: []business.InstallerDelegation{d}}, req } + +// THE MODULE INSTALLER'S PATH validates the ceiling too, and that was the gap. +// +// The write-time rule reached InstallSolution and CreateAgentPrincipal and missed +// ReconcileModuleInstallation — the one path a caller the host does not control +// reaches. The consequence was not a silent grant but a MISLEADING one: the write +// succeeded, the installation read as configured, and every later mint was refused +// with "outside allowed audiences this host still serves", which asserts the +// audience WAS served and has been withdrawn. An operator then hunts a withdrawal +// that never happened. +// +// Asserted on the REFUSAL NAMING THE VALUE, and on the store never being reached: +// a refusal after the write would leave the dead ceiling behind. +func TestTheModuleInstallerPathRefusesAnAudienceThisHostDoesNotServe(t *testing.T) { + svc, store, caller, policy, req := authorityInstallationFixture(t, "installer-audience") + req.AllowedAudiences = []string{"example.api", "nothing-serves-this"} + // The delegation must agree with the request, or the refusal could come from + // the ceiling comparison instead of from the vocabulary. + policy.Delegations[0].AllowedAudiences = slices.Clone(req.AllowedAudiences) + + _, err := svc.ReconcileModuleInstallation(context.Background(), caller, policy, req, true) + require.Error(t, err) + require.ErrorIs(t, err, business.ErrAudienceNotInHostVocabulary) + require.Contains(t, err.Error(), "nothing-serves-this", + "the refusal must name the entry an operator has to fix") + require.NotContains(t, err.Error(), "withdrawn", + "an audience that was never served must not be reported as withdrawn") + require.Zero(t, store.calls, + "the refusal must precede the write, or the dead ceiling is stored anyway") + + // And the same request with every entry served is accepted, so the case above + // is not the fixture refusing everything. + req.AllowedAudiences = []string{"example.api", "example.worker"} + policy.Delegations[0].AllowedAudiences = slices.Clone(req.AllowedAudiences) + _, err = svc.ReconcileModuleInstallation(context.Background(), caller, policy, req, true) + require.NoError(t, err) + require.Equal(t, 1, store.calls) +} func verifiedAuthority(t *testing.T, svc *business.Service, caller business.ModuleCaller, policy *business.InstallerPolicy, req business.ModuleInstallationRequest) *business.ModuleInstallationAuthorityReference { t.Helper() result, err := svc.ReconcileModuleInstallation(context.Background(), caller, policy, req, false) @@ -123,8 +175,8 @@ func TestInstallationAuthorityChangesForEveryAuthorityDimension(t *testing.T) { req.AgentIdentifier = req.ModuleID + ":2.0.0" d.AgentIdentifiers = []string{req.AgentIdentifier} case "solution": - req.SolutionIdentifier = "another-solution" - d.SolutionIdentifier = req.SolutionIdentifier + req.TargetID = "55555555-5555-4555-8555-555555555555" + d.TargetID = req.TargetID case "role": req.RoleID = id d.RoleID = id @@ -211,7 +263,15 @@ func TestInstallationAuthorityCanonicalGolden(t *testing.T) { got := verifiedAuthority(t, svc, caller, policy, req) // Pinned independently from the documented canonical JSON, not from the // production encoder. Changing the contract requires a version decision. - require.Equal(t, "sha256:55816f0b3346273e460d1745e4c46f6c74a63a0baf4ed755fe3dcc394f74e570", got.Digest) + // + // v2: `solution_identifier` became `target_id`. That is a field change, so + // the document says it needs a schema decision rather than a new digest, and + // this is that decision made — the version string moved with the field. The + // digest below was recomputed from MODULE_INSTALLATION.md's stated rules + // (one object, sorted keys, compact UTF-8, no trailing newline) and NOT by + // copying what the encoder produced, which is the only thing that makes this + // a pin rather than a mirror. + require.Equal(t, "sha256:d80a031de7ff432228adf51c84a5de39a9743b408a6cc42d6b26e974c4cc6b15", got.Digest) require.False(t, strings.Contains(got.Digest, req.AgentIdentifier)) } diff --git a/module/services/accounts/code/pkg/business/module_installations.go b/module/services/accounts/code/pkg/business/module_installations.go index 4bb206d78..9f1cccebf 100644 --- a/module/services/accounts/code/pkg/business/module_installations.go +++ b/module/services/accounts/code/pkg/business/module_installations.go @@ -19,17 +19,21 @@ import ( // ModuleInstallationRequest describes one immutable organization installation. // The accountable owner and delegation are never accepted from this request. type ModuleInstallationRequest struct { - AuthorityReferenceVersion string `json:"authorityReferenceVersion,omitempty"` - ModuleID string `json:"moduleId"` - OrganizationSlug string `json:"organizationSlug"` - AgentIdentifier string `json:"agentIdentifier"` - SolutionIdentifier string `json:"solutionIdentifier"` - RoleID string `json:"roleId"` - ExpectedRolePermissions []string `json:"expectedRolePermissions"` - AllowedAudiences []string `json:"allowedAudiences"` - AllowedScopes []string `json:"allowedScopes"` - DisplayName string `json:"displayName"` - RootScopeLabel string `json:"rootScopeLabel"` + AuthorityReferenceVersion string `json:"authorityReferenceVersion,omitempty"` + ModuleID string `json:"moduleId"` + OrganizationSlug string `json:"organizationSlug"` + AgentIdentifier string `json:"agentIdentifier"` + // TargetID is the immutable solution target this installation is for, from + // the catalogue of accepted applied presence. It replaced a free-text + // solution identifier: a declaration naming an alias installed whatever held + // that alias when the declaration was applied, and whatever took it later. + TargetID string `json:"targetId"` + RoleID string `json:"roleId"` + ExpectedRolePermissions []string `json:"expectedRolePermissions"` + AllowedAudiences []string `json:"allowedAudiences"` + AllowedScopes []string `json:"allowedScopes"` + DisplayName string `json:"displayName"` + RootScopeLabel string `json:"rootScopeLabel"` } type ModuleInstallationResult struct { @@ -47,17 +51,18 @@ type ModuleInstallationResult struct { // owner. Deployment possession alone grants nothing. The policy projection is // read on EVERY request; deleting a delegation revokes outstanding capabilities. type InstallerDelegation struct { - Prefix string `json:"prefix"` - OrganizationID string `json:"organizationId"` - ModuleID string `json:"moduleId"` - AgentIdentifiers []string `json:"agentIdentifiers"` - SolutionIdentifier string `json:"solutionIdentifier"` - RoleID string `json:"roleId"` - RolePermissions []string `json:"rolePermissions"` - AllowedAudiences []string `json:"allowedAudiences"` - AllowedScopes []string `json:"allowedScopes"` - OwnerPrincipalID string `json:"ownerPrincipalId"` - ExpiresAt time.Time `json:"expiresAt"` + Prefix string `json:"prefix"` + OrganizationID string `json:"organizationId"` + ModuleID string `json:"moduleId"` + AgentIdentifiers []string `json:"agentIdentifiers"` + // TargetID is the one solution target this delegation authorises installing. + TargetID string `json:"targetId"` + RoleID string `json:"roleId"` + RolePermissions []string `json:"rolePermissions"` + AllowedAudiences []string `json:"allowedAudiences"` + AllowedScopes []string `json:"allowedScopes"` + OwnerPrincipalID string `json:"ownerPrincipalId"` + ExpiresAt time.Time `json:"expiresAt"` } type InstallerPolicy struct { @@ -115,7 +120,7 @@ func ParseInstallerPolicy(reader io.Reader) (*InstallerPolicy, error) { return nil, errors.New("duplicate installer delegation") } seen[key] = true - if !registrationIdentityPattern.MatchString(d.Prefix) || len(d.Prefix) > 63 || !looksLikeAgentIdentifier(d.ModuleID+":1") || d.SolutionIdentifier == "" || len(d.SolutionIdentifier) > 200 || d.ExpiresAt.IsZero() { + if !registrationIdentityPattern.MatchString(d.Prefix) || len(d.Prefix) > 63 || !looksLikeAgentIdentifier(d.ModuleID+":1") || d.TargetID == "" || len(d.TargetID) > 200 || d.ExpiresAt.IsZero() { return nil, errors.New("invalid installer delegation identity or expiry") } for _, id := range []string{d.OrganizationID, d.OwnerPrincipalID, d.RoleID} { @@ -151,7 +156,7 @@ func (p *InstallerPolicy) AllowsIdentity(caller ModuleCaller, now time.Time) boo func (p *InstallerPolicy) Authorize(caller ModuleCaller, req ModuleInstallationRequest, now time.Time) (InstallerDelegation, error) { if p != nil { for _, d := range p.Delegations { - if ModulePrincipalID(d.Prefix) == caller.PrincipalID && d.OrganizationID == caller.BoundOrg && d.ModuleID == req.ModuleID && now.Before(d.ExpiresAt) && slices.Contains(d.AgentIdentifiers, req.AgentIdentifier) && d.SolutionIdentifier == req.SolutionIdentifier && d.RoleID == req.RoleID && exactSet(d.RolePermissions, req.ExpectedRolePermissions) && exactSet(d.AllowedAudiences, req.AllowedAudiences) && exactSet(d.AllowedScopes, req.AllowedScopes) { + if ModulePrincipalID(d.Prefix) == caller.PrincipalID && d.OrganizationID == caller.BoundOrg && d.ModuleID == req.ModuleID && now.Before(d.ExpiresAt) && slices.Contains(d.AgentIdentifiers, req.AgentIdentifier) && d.TargetID == req.TargetID && d.RoleID == req.RoleID && exactSet(d.RolePermissions, req.ExpectedRolePermissions) && exactSet(d.AllowedAudiences, req.AllowedAudiences) && exactSet(d.AllowedScopes, req.AllowedScopes) { return d, nil } } @@ -187,11 +192,27 @@ func (s *Service) ReconcileModuleInstallation(ctx context.Context, caller Module if !ok { return nil, errors.New("installer persistence unavailable") } + // The ceiling may only name audiences this host SERVES (issue #952), and this + // is the THIRD write path for it — the module installer's, reached by a caller + // the host does not control, unlike InstallSolution and CreateAgentPrincipal. + // It was missed, and the consequence was not a silent grant but a MISLEADING + // one: the write succeeded, the installation read as configured, and every + // later mint was refused with "outside allowed audiences this host still + // serves" — which asserts the audience WAS served and has been withdrawn, so + // an operator went looking for a withdrawal that never happened. + // + // Checked BEFORE the tenant transaction opens, for the same reason + // resolveInstallableTarget is hoisted in installations.go: the vocabulary's + // solution half is a control-plane read, and the request role holds no grant on + // the presence relations. + if err := s.RequireHostAudiences(ctx, req.AllowedAudiences); err != nil { + return nil, err + } var result *ModuleInstallationResult err = s.store.WithOrgTx(ctx, org.Id, func(ctx context.Context) error { var err error result, err = store.ReconcileModuleInstallation(ctx, &InstallSolutionParams{ - OrgID: org.Id, AgentIdentifier: req.AgentIdentifier, SolutionIdentifier: req.SolutionIdentifier, + OrgID: org.Id, AgentIdentifier: req.AgentIdentifier, TargetID: req.TargetID, RoleID: req.RoleID, AllowedAudiences: req.AllowedAudiences, AllowedScopes: req.AllowedScopes, DisplayName: req.DisplayName, RootScopeLabel: req.RootScopeLabel, OwnerPrincipalID: delegation.OwnerPrincipalID, GrantedBy: delegation.OwnerPrincipalID, @@ -209,10 +230,10 @@ func (s *Service) ReconcileModuleInstallation(ctx context.Context, caller Module if !result.Changed { return nil } - if err = s.publishLifecycleEvent(ctx, EventInstallationCreated, org.Id, result.ScopeNodeID, caller.PrincipalID, map[string]any{"installation_id": result.InstallationID, "agent_principal_id": result.PrincipalID, "solution_identifier": req.SolutionIdentifier}); err != nil { + if err = s.publishLifecycleEvent(ctx, EventInstallationCreated, org.Id, result.ScopeNodeID, caller.PrincipalID, map[string]any{"installation_id": result.InstallationID, "agent_principal_id": result.PrincipalID, "target_id": req.TargetID}); err != nil { return err } - return s.emitTx(ctx, caller.PrincipalID, ActorTypeSystem, EventInstallationCreated, "installation", result.InstallationID, org.Id, map[string]any{"agent_principal_id": result.PrincipalID, "solution_identifier": req.SolutionIdentifier, "role_id": req.RoleID, "owner_principal_id": delegation.OwnerPrincipalID}) + return s.emitTx(ctx, caller.PrincipalID, ActorTypeSystem, EventInstallationCreated, "installation", result.InstallationID, org.Id, map[string]any{"agent_principal_id": result.PrincipalID, "target_id": req.TargetID, "role_id": req.RoleID, "owner_principal_id": delegation.OwnerPrincipalID}) }) if err != nil { // Never expose a verified authority reference from a transaction whose diff --git a/module/services/accounts/code/pkg/business/module_notify_org_admins_integration_test.go b/module/services/accounts/code/pkg/business/module_notify_org_admins_integration_test.go index 8b3ec5451..1101a5920 100644 --- a/module/services/accounts/code/pkg/business/module_notify_org_admins_integration_test.go +++ b/module/services/accounts/code/pkg/business/module_notify_org_admins_integration_test.go @@ -30,6 +30,7 @@ func TestModuleNotifyOrgAdmins_ReachesOnlyTheTenantsAdministrators(t *testing.T) svc, err := business.NewService(testStore) require.NoError(t, err) backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{modulePrincSvc: {}}) caller := business.ModuleCaller{PrincipalID: modulePrincSvc, BoundOrg: org} @@ -109,6 +110,12 @@ func TestModuleNotifyUser_StoresAndResolvesASameOriginDestination(t *testing.T) require.NoError(t, err) backend := &fakeJobBackend{} svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{modulePrincSvc: {}}) + // Every capability path re-reads LIVE module authority before it acts, so a + // service with no live read authorizes nothing at all rather than deciding on + // the declared ceiling alone. This test arrived against the older contract, + // where each path read only the declared grant; wiring the live read is the + // same one line every other capability test here carries. + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) caller := business.ModuleCaller{PrincipalID: modulePrincSvc, BoundOrg: org} const destination = "/documents/doc-7/comments/3?page=2#reply" diff --git a/module/services/accounts/code/pkg/business/module_operation_audience.go b/module/services/accounts/code/pkg/business/module_operation_audience.go index 26b8c1c7f..37f9c1b84 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience.go @@ -2,6 +2,7 @@ package business import ( "bytes" + "context" "encoding/json" "fmt" "slices" @@ -151,8 +152,11 @@ func validateOperationAudiences(prefix string, bindings map[string]ModuleOperati return nil } -func (s *Service) ModuleOperationAudience(caller ModuleCaller, tenant, parentAudience, bindingID string) (ModuleOperationAudience, error) { - grant, err := s.moduleGrant(caller) +// ctx is a parameter because this path RE-READS live authority, and a function +// that took none could not. It had none, which is why it was one of the paths +// deciding on the declared ceiling alone. +func (s *Service) ModuleOperationAudience(ctx context.Context, caller ModuleCaller, tenant, parentAudience, bindingID string) (ModuleOperationAudience, error) { + grant, err := s.moduleCapability(ctx, caller) if err != nil { return ModuleOperationAudience{}, err } @@ -177,11 +181,11 @@ func (s *Service) ModuleOperationAudience(caller ModuleCaller, tenant, parentAud // caller's declared tenant or cross_tenant grant, is what admits the tenant. // The parent must still be addressed to the caller, and the binding must still // be one the caller declares. -func (s *Service) ModuleOperationAudienceForDelegation(caller ModuleCaller, delegation *SourceDelegation, parentAudience, bindingID string) (ModuleOperationAudience, error) { +func (s *Service) ModuleOperationAudienceForDelegation(ctx context.Context, caller ModuleCaller, delegation *SourceDelegation, parentAudience, bindingID string) (ModuleOperationAudience, error) { if !delegation.Active() { return ModuleOperationAudience{}, status.Error(codes.PermissionDenied, "source delegation is not active") } - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return ModuleOperationAudience{}, err } diff --git a/module/services/accounts/code/pkg/business/module_operation_audience_test.go b/module/services/accounts/code/pkg/business/module_operation_audience_test.go index 653ad8532..ab659a04e 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience_test.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience_test.go @@ -13,7 +13,7 @@ func TestInstalledOperationAudiencePolicy(t *testing.T) { InvokeScopes: []ModuleOperationScope{{ResourceKind: "results", Actions: []string{"read", "write"}, ResourceIDs: []string{"result-1"}}}, LookupScopes: []ModuleOperationScope{{ResourceKind: "results", Actions: []string{"read"}, ResourceIDs: []string{"result-1"}}}, } - raw, err := json.Marshal(map[string]any{"example": map[string]any{"tenant": "019f6bf7-5b4b-74e5-8c17-092259bb1661", "operation_audiences": map[string]ModuleOperationAudience{"generate": valid}}}) + raw, err := json.Marshal(map[string]any{"example": map[string]any{"workload": map[string]any{"service_account": "module", "namespace": "acme-prod", "container": "app"}, "tenant": "019f6bf7-5b4b-74e5-8c17-092259bb1661", "operation_audiences": map[string]ModuleOperationAudience{"generate": valid}}}) require.NoError(t, err) registry, err := ParseModulePrincipalRegistry(string(raw)) require.NoError(t, err) @@ -36,8 +36,8 @@ func TestInstalledOperationAudiencePolicy(t *testing.T) { func TestInstalledOperationAudienceRejectsUnknownFieldsAndNonCanonicalSets(t *testing.T) { for _, raw := range []string{ - `{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"generate":{"audience":"other","scopes":[]}}}}`, - `{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"generate":{"audience":"other","invoke_scopes":[{"resource_kind":"results","actions":["write","read"]}],"lookup_scopes":[{"resource_kind":"results","actions":["read"]}]}}}}`, + `{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"generate":{"audience":"other","scopes":[]}}}}`, + `{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"generate":{"audience":"other","invoke_scopes":[{"resource_kind":"results","actions":["write","read"]}],"lookup_scopes":[{"resource_kind":"results","actions":["read"]}]}}}}`, "{\"example\":{\"tenant\":\"019f6bf7-5b4b-74e5-8c17-092259bb1661\",\"operation_audiences\":{\"generate\":{\"audience\":\"other\",\"invoke_scopes\":[{\"resource_kind\":\"results\",\"actions\":[\"read\\n\"]}],\"lookup_scopes\":[{\"resource_kind\":\"results\",\"actions\":[\"read\"]}]}}}}", } { _, err := ParseModulePrincipalRegistry(raw) @@ -87,14 +87,14 @@ func TestInstalledOperationAudienceHeadlessScopes(t *testing.T) { }) } - raw := `{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"model":{"audience":"other",` + + raw := `{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"model":{"audience":"other",` + `"invoke_scopes":[{"resource_kind":"profiles","actions":["invoke"]}],` + `"lookup_scopes":[{"resource_kind":"profiles","actions":["read"]}],` + `"headless_scopes":[{"resource_kind":"profiles","actions":["invoke"],"resource_ids":["p-1"]}]}}}}` _, err := ParseModulePrincipalRegistry(raw) require.Error(t, err, "lookup scopes must still be a subset of invoke scopes") - raw = `{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"model":{"audience":"other",` + + raw = `{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"model":{"audience":"other",` + `"invoke_scopes":[{"resource_kind":"profiles","actions":["invoke","read"]}],` + `"lookup_scopes":[{"resource_kind":"profiles","actions":["read"]}],` + `"headless_scopes":[{"resource_kind":"profiles","actions":["invoke"],"resource_ids":["p-1"]}]}}}}` @@ -105,7 +105,7 @@ func TestInstalledOperationAudienceHeadlessScopes(t *testing.T) { } func TestModuleAuthorizeOperationContext(t *testing.T) { - registry, err := ParseModulePrincipalRegistry(`{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{` + + registry, err := ParseModulePrincipalRegistry(`{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{` + `"model":{"audience":"other","invoke_scopes":[{"resource_kind":"profiles","actions":["invoke","read"]}],` + `"lookup_scopes":[{"resource_kind":"profiles","actions":["read"]}],` + `"headless_scopes":[{"resource_kind":"profiles","actions":["invoke"],"resource_ids":["p-1"]}]},` + @@ -114,7 +114,10 @@ func TestModuleAuthorizeOperationContext(t *testing.T) { require.NoError(t, err) secrets, err := ParseRegistrationSecrets("example:" + declarationDigest("example-secret")) require.NoError(t, err) - s := &Service{modulePrincipals: registry} + s := &Service{} + withCurrentAuthority(s) + withCurrentAuthority(s) + s.SetModulePrincipals(registry) s.SetModuleIdentitySecrets(secrets) authority, err := s.ModuleAuthorizeOperationContext("example", "example-secret", "model") @@ -143,7 +146,7 @@ func TestModuleAuthorizeOperationContext(t *testing.T) { } func TestModuleOperationContextRevisionIsADeterministicDigestOfTheGrant(t *testing.T) { - raw := `{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","queues":["a"],"operation_audiences":{` + + raw := `{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","queues":["a"],"operation_audiences":{` + `"model":{"audience":"other","invoke_scopes":[{"resource_kind":"profiles","actions":["invoke","read"]}],` + `"lookup_scopes":[{"resource_kind":"profiles","actions":["read"]}],` + `"headless_scopes":[{"resource_kind":"profiles","actions":["invoke"],"resource_ids":["p-1"]}]}}}}` @@ -163,7 +166,10 @@ func TestModuleOperationContextRevisionIsADeterministicDigestOfTheGrant(t *testi changed.Queues = []string{"b"} require.NotEqual(t, revision, ModuleOperationContextRevision(changed)) - s := &Service{modulePrincipals: first} + s := &Service{} + withCurrentAuthority(s) + withCurrentAuthority(s) + s.SetModulePrincipals(first) owner := []ModuleOperationRevisionSubject{{PrincipalID: id, Scopes: []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"invoke"}, ResourceIDs: []string{"p-1"}}}}} handled, err := s.CheckModuleOperationContextRevision("019f6bf7-5b4b-74e5-8c17-092259bb1661", id, revision, owner) require.True(t, handled) diff --git a/module/services/accounts/code/pkg/business/module_operation_context.go b/module/services/accounts/code/pkg/business/module_operation_context.go index 0ee20af23..15b53993d 100644 --- a/module/services/accounts/code/pkg/business/module_operation_context.go +++ b/module/services/accounts/code/pkg/business/module_operation_context.go @@ -59,7 +59,7 @@ func (s *Service) ModuleAuthorizeOperationContext(prefix, secret, bindingID stri if err != nil { return ModuleOperationContextAuthority{}, err } - grant, registered := s.modulePrincipals[identity.PrincipalID] + grant, registered := s.declaredModules()[identity.PrincipalID] if !registered { return ModuleOperationContextAuthority{}, ErrModuleRegistrationDenied } @@ -179,7 +179,7 @@ var ErrModuleOperationContextStale = errors.New("module operation context is not func (s *Service) CheckModuleOperationContextRevision( orgID, ownerPrincipalID string, revision uint64, subjects []ModuleOperationRevisionSubject, ) (handled bool, err error) { - grant, isModule := s.modulePrincipals[ownerPrincipalID] + grant, isModule := s.declaredModules()[ownerPrincipalID] if !isModule { return false, nil } diff --git a/module/services/accounts/code/pkg/business/module_place_record_test.go b/module/services/accounts/code/pkg/business/module_place_record_test.go index f46790788..c68a2cfaf 100644 --- a/module/services/accounts/code/pkg/business/module_place_record_test.go +++ b/module/services/accounts/code/pkg/business/module_place_record_test.go @@ -37,6 +37,7 @@ func newPlacementService(t *testing.T, store business.Store, grant business.Modu if err != nil { t.Fatalf("NewService: %v", err) } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{modulePrincSvc: grant}) return svc } diff --git a/module/services/accounts/code/pkg/business/module_principal_directory_test.go b/module/services/accounts/code/pkg/business/module_principal_directory_test.go index 39ddb2048..31bdade67 100644 --- a/module/services/accounts/code/pkg/business/module_principal_directory_test.go +++ b/module/services/accounts/code/pkg/business/module_principal_directory_test.go @@ -16,8 +16,7 @@ type pagedPrincipalStore struct { next string // gotPageSize records the budget the business layer actually asked the store - // for, which is how a test can say whether the projected modules were taken - // out of the caller's page or added on top of it. + // for, so the test can verify the page budget reaches the store unchanged. gotPageSize int32 } @@ -32,72 +31,6 @@ func (s *pagedPrincipalStore) ListPrincipals(_ context.Context, _, kind string, return out, s.next, nil } -// A composed module is a principal with no stored row: it acts in the org, holds -// grants there, and is the actor of the audit rows it records. The directory an -// org's principals are named from lists it — by its prefix, as a service -// principal — or every surface built on that directory shows it as unknown -// ("Actor unavailable" on the audit log). Only modules that may act in the org -// are listed: the one bound to it and the cross-tenant ones, never another -// org's. -// -// They lead the FIRST page. A directory walk reads a bounded number of pages -// and stops once every actor it wants is named, so a module listed only on the -// last page is never reached in an org with more principals than the walk -// reads — the org where naming it matters most. -// -// The registry is parsed from the deployment document rather than built by -// hand, so it has the shape production runs with: every grant declares a -// tenant, the cross-tenant module's being another org. The documents module's -// tenant is declared in uppercase; it is still this org. -func TestListPrincipals_NamesTheModulesActingInTheOrg(t *testing.T) { - const ( - org = "019f6bf7-6a01-7001-8001-0000000000c1" - orgUpper = "019F6BF7-6A01-7001-8001-0000000000C1" - otherOrg = "019f6bf7-6a01-7001-8001-0000000000c2" - ) - human := &Principal{ID: "019f6bf7-6a01-7001-8001-0000000000a1", Kind: PrincipalKindHuman, DisplayName: "admin@example.com"} - registry, err := ParseModulePrincipalRegistry(`{ - "documents": {"tenant": "` + orgUpper + `"}, - "assistant": {"tenant": "` + otherOrg + `", "cross_tenant": true}, - "elsewhere": {"tenant": "` + otherOrg + `"} - }`) - require.NoError(t, err) - list := func(t *testing.T, orgID, kind, pageToken, next string) ([]*Principal, string) { - t.Helper() - svc, err := NewService(&pagedPrincipalStore{rows: []*Principal{human}, next: next}) - require.NoError(t, err) - svc.SetModulePrincipals(registry) - out, token, err := svc.ListPrincipals(context.Background(), orgID, kind, 50, pageToken) - require.NoError(t, err) - return out, token - } - assistant := &Principal{ID: ModulePrincipalID("assistant"), Kind: PrincipalKindService, DisplayName: "assistant", OrgID: org} - documents := &Principal{ID: ModulePrincipalID("documents"), Kind: PrincipalKindService, DisplayName: "documents", OrgID: org} - - out, token := list(t, org, "", "", "") - require.Empty(t, token) - require.Equal(t, []*Principal{assistant, documents, human}, out, - "the org's modules lead, ordered by prefix; the stored rows follow untouched") - - firstPage, token := list(t, org, "", "", "more") - require.Equal(t, "more", token, "the stored rows' cursor is passed through unchanged") - require.Equal(t, []*Principal{assistant, documents, human}, firstPage, - "the modules are on the first page even when more stored rows follow") - - laterPage, _ := list(t, org, "", "cursor", "") - require.Equal(t, []*Principal{human}, laterPage, "a later page lists no module a second time") - - serviceOnly, _ := list(t, org, PrincipalKindService, "", "") - require.Equal(t, []*Principal{assistant, documents}, serviceOnly, "a module principal is a service principal") - - humans, _ := list(t, org, PrincipalKindHuman, "", "") - require.Equal(t, []*Principal{human}, humans, "a kind filter that excludes services lists no module") - - upper, _ := list(t, orgUpper, "", "", "") - require.Equal(t, []*Principal{assistant, documents, human}, upper, - "an org id spelled in uppercase still names its own modules") -} - // A deployment that spells a module's tenant in any form uuid.Parse accepts gets // the canonical form back. Every downstream comparison — the directory, the // operation-context revision check, the bound org a Work Context seals — is a @@ -113,48 +46,9 @@ func TestParseModulePrincipalRegistry_CanonicalizesTheTenant(t *testing.T) { "019f6bf76a01700180010000000000c1", } { t.Run(spelling, func(t *testing.T) { - registry, err := ParseModulePrincipalRegistry(`{"documents": {"tenant": "` + spelling + `"}}`) + registry, err := ParseModulePrincipalRegistry(`{"documents": {"workload": {"service_account": "module", "namespace": "acme-prod", "container": "app"}, "tenant": "` + spelling + `"}}`) require.NoError(t, err) require.Equal(t, canonical, registry[ModulePrincipalID("documents")].Tenant) }) } } - -// The projected modules come out of the page's own budget, so a page still holds -// at most the pageSize rows the caller asked for, and the keyset token still -// describes exactly the stored rows the store returned. Adding them on top would -// hand a caller more rows than it asked for and a token that does not account for -// them. -func TestListPrincipals_ModulesComeOutOfThePageBudget(t *testing.T) { - const ( - org = "019f6bf7-6a01-7001-8001-0000000000c1" - otherOrg = "019f6bf7-6a01-7001-8001-0000000000c2" - ) - human := &Principal{ID: "019f6bf7-6a01-7001-8001-0000000000a1", Kind: PrincipalKindHuman, DisplayName: "admin@example.com"} - registry, err := ParseModulePrincipalRegistry(`{ - "documents": {"tenant": "` + org + `"}, - "assistant": {"tenant": "` + otherOrg + `", "cross_tenant": true} - }`) - require.NoError(t, err) - list := func(t *testing.T, pageToken, next string) ([]*Principal, *pagedPrincipalStore) { - t.Helper() - store := &pagedPrincipalStore{rows: []*Principal{human}, next: next} - svc, err := NewService(store) - require.NoError(t, err) - svc.SetModulePrincipals(registry) - out, _, err := svc.ListPrincipals(context.Background(), org, "", 50, pageToken) - require.NoError(t, err) - return out, store - } - - first, firstStore := list(t, "", "more") - require.Len(t, first, 3, "the two modules acting here, and the stored row") - require.Equal(t, int32(48), firstStore.gotPageSize, - "the two modules take two of the fifty rows the caller asked for") - - // A page reached through a token carries no module, so the whole budget is the - // store's. - later, laterStore := list(t, "cursor", "") - require.Equal(t, []*Principal{human}, later) - require.Equal(t, int32(50), laterStore.gotPageSize) -} diff --git a/module/services/accounts/code/pkg/business/module_principal_registry_race_test.go b/module/services/accounts/code/pkg/business/module_principal_registry_race_test.go new file mode 100644 index 000000000..3d4d4c9d9 --- /dev/null +++ b/module/services/accounts/code/pkg/business/module_principal_registry_race_test.go @@ -0,0 +1,171 @@ +package business + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// The module principal registry is REPLACED, never mutated: every writer swaps a +// whole registry in. As a bare field that swap was an unsynchronized write +// against the eighteen authorization sites that read it, which is undefined +// behaviour rather than a merely stale read. +// +// Nothing replaces it at runtime today — work.go wires it once at boot — so the +// race is latent. It goes live the moment a platform administrator can narrow a +// module's ceiling without a redeploy, which is exactly what the envelope record +// introduces. This test is what keeps the field behind an atomic pointer until +// then: under `-race` it fails against the bare field and passes against the +// pointer. +func TestModulePrincipalRegistryIsReplacedWithoutRacingItsReaders(t *testing.T) { + service := &Service{} + narrow := ModulePrincipalRegistry{ + ModulePrincipalID("example"): {Prefix: "example", Queues: []string{"a"}}, + } + wide := ModulePrincipalRegistry{ + ModulePrincipalID("example"): {Prefix: "example", Queues: []string{"a", "b"}}, + ModulePrincipalID("other"): {Prefix: "other"}, + } + // BOOT WIRING, and it belongs HERE rather than inside the goroutines below. + // + // This test could not pass under `-race` whatever the registry did, which is + // the one outcome it must never have: its whole claim is "fails against the + // bare field, passes against the pointer". The loops below used to call + // withCurrentAuthority, which calls SetModuleAuthorityReads, which assigns + // two BARE fields — correctly, because its production contract is boot-only + // (one call in work.go, before the service listens). Eight goroutines + // calling it therefore raced on fields that are NOT this test's subject, and + // `-race` reported those instead of the registry. A test that fails for a + // reason unrelated to its subject reports nothing about its subject. + // + // The readers below need no live authority read at all — they call + // declaredModules(), which reads only the registry — so hoisting it loses + // no coverage. + withCurrentAuthority(service) + service.SetModulePrincipals(narrow) + + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for i := range 500 { + if i%2 == 0 { + service.SetModulePrincipals(wide) + continue + } + service.SetModulePrincipals(narrow) + } + }() + } + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for range 500 { + // The two shapes of read every authorization site uses: a + // lookup by principal, and a whole-registry derivation. + _, _ = service.declaredModules()[ModulePrincipalID("example")] + _ = service.declaredModules().ContentResources() + } + }() + } + wg.Wait() +} + +// An unset registry denies every module caller rather than admitting all of +// them. It read that way as a nil map and must keep reading that way behind the +// pointer: this is the one place where losing the fail-closed default would be +// invisible, because a service with no declared modules is exactly what a +// misconfigured deployment has. +func TestUndeclaredModuleRegistryDeniesEveryCaller(t *testing.T) { + service := &Service{} + require.Empty(t, service.declaredModules(), + "an unset registry must read as empty, which authorizes no module") + _, registered := service.declaredModules()[ModulePrincipalID("example")] + require.False(t, registered) + require.Empty(t, service.declaredModules().ContentResources(), + "no declared content resources means nothing may be placed") + require.Empty(t, service.ModulePrincipals(), + "the exported accessor answers the same empty registry") +} + +// The atomic pointer guards the map HEADER. These two tests guard the map. +// +// Both reviews of #953 made the same point: a pointer swap synchronizes nothing +// if the backing map stays reachable from outside, because a writer mutating it +// in place swaps no pointer and so crosses no barrier. The registry was being +// stored as the CALLER'S map and handed back out by the exported accessor, so +// there were two such routes. +// +// Deliberately NOT race-dependent. The earlier test in this file can only fail +// under `-race`, which CI runs on one package — so it proves little where it +// matters. These fail on a plain `go test` by observing the authorization answer +// change, which is the thing that actually goes wrong. +func TestModulePrincipalRegistryIsNotMutableThroughTheMapItWasGiven(t *testing.T) { + service := &Service{} + caller := ModulePrincipalRegistry{ + ModulePrincipalID("example"): {Prefix: "example", Queues: []string{"a"}}, + } + withCurrentAuthority(service) + withCurrentAuthority(service) + service.SetModulePrincipals(caller) + + // The caller keeps its map and widens the grant it already handed over, and + // adds a principal that was never declared. + caller[ModulePrincipalID("example")] = ModulePrincipalGrant{ + Prefix: "example", Queues: []string{"a", "b"}, CrossTenant: true, + } + caller[ModulePrincipalID("smuggled")] = ModulePrincipalGrant{Prefix: "smuggled"} + + granted := service.declaredModules()[ModulePrincipalID("example")] + require.Equal(t, []string{"a"}, granted.Queues, + "a widened queue list must not reach the registry the service authorizes against") + require.False(t, granted.CrossTenant, + "cross-tenant must not be grantable by mutating the map the caller passed in") + _, smuggled := service.declaredModules()[ModulePrincipalID("smuggled")] + require.False(t, smuggled, + "a principal added after the store must not become declared") +} + +func TestModulePrincipalRegistryIsNotMutableThroughTheAccessor(t *testing.T) { + service := &Service{} + withCurrentAuthority(service) + withCurrentAuthority(service) + service.SetModulePrincipals(ModulePrincipalRegistry{ + ModulePrincipalID("example"): {Prefix: "example", Queues: []string{"a"}}, + }) + + // A caller reads the declaration and writes into what it was handed. + read := service.ModulePrincipals() + read[ModulePrincipalID("example")] = ModulePrincipalGrant{ + Prefix: "example", Queues: []string{"a", "b"}, CrossTenant: true, + } + read[ModulePrincipalID("smuggled")] = ModulePrincipalGrant{Prefix: "smuggled"} + + granted := service.declaredModules()[ModulePrincipalID("example")] + require.Equal(t, []string{"a"}, granted.Queues, + "the accessor must not hand out the registry the service authorizes against") + require.False(t, granted.CrossTenant) + _, smuggled := service.declaredModules()[ModulePrincipalID("smuggled")] + require.False(t, smuggled) +} + +// Clearing the registry denies every module caller, rather than leaving the +// previous one serving. maps.Clone of a nil map is nil, and both an unset +// pointer and a nil registry read as empty — so the fail-closed answer survives +// the clone that was added to make the registry unreachable from outside. +func TestModulePrincipalRegistryClearedDeniesEveryCaller(t *testing.T) { + service := &Service{} + withCurrentAuthority(service) + withCurrentAuthority(service) + service.SetModulePrincipals(ModulePrincipalRegistry{ + ModulePrincipalID("example"): {Prefix: "example", Queues: []string{"a"}}, + }) + withCurrentAuthority(service) + withCurrentAuthority(service) + service.SetModulePrincipals(nil) + require.Empty(t, service.declaredModules(), + "a cleared registry must authorize nobody, never keep the previous declaration") +} diff --git a/module/services/accounts/code/pkg/business/module_read_audience.go b/module/services/accounts/code/pkg/business/module_read_audience.go index ab49e686b..b553213b1 100644 --- a/module/services/accounts/code/pkg/business/module_read_audience.go +++ b/module/services/accounts/code/pkg/business/module_read_audience.go @@ -2,6 +2,7 @@ package business import ( "bytes" + "context" "encoding/json" "fmt" "strings" @@ -62,8 +63,10 @@ func validateReadAudiences(prefix string, bindings map[string]ModuleReadAudience return nil } -func (s *Service) ModuleReadAudience(caller ModuleCaller, tenant, parentAudience, bindingID string) (ModuleReadAudience, error) { - grant, err := s.moduleGrant(caller) +// ctx is a parameter for the same reason ModuleOperationAudience's is: this +// path re-reads live authority, and it previously could not. +func (s *Service) ModuleReadAudience(ctx context.Context, caller ModuleCaller, tenant, parentAudience, bindingID string) (ModuleReadAudience, error) { + grant, err := s.moduleCapability(ctx, caller) if err != nil { return ModuleReadAudience{}, err } diff --git a/module/services/accounts/code/pkg/business/module_read_audience_test.go b/module/services/accounts/code/pkg/business/module_read_audience_test.go index 756862ea7..ae1085322 100644 --- a/module/services/accounts/code/pkg/business/module_read_audience_test.go +++ b/module/services/accounts/code/pkg/business/module_read_audience_test.go @@ -9,7 +9,7 @@ import ( func TestInstalledReadAudiencePolicy(t *testing.T) { valid := ModuleReadAudience{Audience: "example-producer", Scopes: []ModuleReadScope{{ResourceKind: "results", ResourceIDs: []string{"result-1"}}}} - raw, _ := json.Marshal(map[string]any{"example": map[string]any{"tenant": "019f6bf7-5b4b-74e5-8c17-092259bb1661", "read_audiences": map[string]ModuleReadAudience{"proof": valid}}}) + raw, _ := json.Marshal(map[string]any{"example": map[string]any{"workload": map[string]any{"service_account": "module", "namespace": "acme-prod", "container": "app"}, "tenant": "019f6bf7-5b4b-74e5-8c17-092259bb1661", "read_audiences": map[string]ModuleReadAudience{"proof": valid}}}) registry, err := ParseModulePrincipalRegistry(string(raw)) require.NoError(t, err) got := registry[ModulePrincipalID("example")].ReadAudiences["proof"] @@ -21,6 +21,6 @@ func TestInstalledReadAudiencePolicy(t *testing.T) { } func TestInstalledReadAudienceRejectsRequestedActions(t *testing.T) { - _, err := ParseModulePrincipalRegistry(`{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","read_audiences":{"proof":{"audience":"other","scopes":[{"resource_kind":"results","actions":["write"]}]}}}}`) + _, err := ParseModulePrincipalRegistry(`{"example":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","read_audiences":{"proof":{"audience":"other","scopes":[{"resource_kind":"results","actions":["write"]}]}}}}`) require.Error(t, err) } diff --git a/module/services/accounts/code/pkg/business/module_registration.go b/module/services/accounts/code/pkg/business/module_registration.go index 420ecdf9a..756b91aa0 100644 --- a/module/services/accounts/code/pkg/business/module_registration.go +++ b/module/services/accounts/code/pkg/business/module_registration.go @@ -224,6 +224,10 @@ func ParseRegistrationSecrets(raw string) (map[string][sha256.Size]byte, error) return secrets, nil } +// TODO(#952, Unit D): delete both registration mints and their secret configuration +// with module/services/accounts/code/pkg/adapters/module_capabilities_server.go. +// That protected file still calls these methods; its owner must remove the RPC +// handlers before this issuer and the work.go configuration readers can be deleted. // ModuleMintRegistration authorizes a module against its declared registration // secret and issues the gateway credential. Every refusal returns // ErrModuleRegistrationDenied. @@ -307,7 +311,7 @@ func (s *Service) ModuleAuthorizeWorkContext(prefix, secret string) (ModuleWorkC return ModuleWorkContextAuthority{}, ErrModuleRegistrationDenied } principalID := ModulePrincipalID(prefix) - grant, registered := s.modulePrincipals[principalID] + grant, registered := s.declaredModules()[principalID] if !registered { return ModuleWorkContextAuthority{}, ErrModuleRegistrationDenied } diff --git a/module/services/accounts/code/pkg/business/module_subject_visibility_test.go b/module/services/accounts/code/pkg/business/module_subject_visibility_test.go index a8b3329a0..a4e488893 100644 --- a/module/services/accounts/code/pkg/business/module_subject_visibility_test.go +++ b/module/services/accounts/code/pkg/business/module_subject_visibility_test.go @@ -102,6 +102,7 @@ func newModuleVisibilityService(t *testing.T, tenant string) (*business.Service, svc, err := business.NewService(testStore) require.NoError(t, err) principal := business.ModulePrincipalID("records") + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ principal: {Prefix: "records", Tenant: tenant}, }) diff --git a/module/services/accounts/code/pkg/business/org_admin_continuity_test.go b/module/services/accounts/code/pkg/business/org_admin_continuity_test.go index fe765041b..95a9b9903 100644 --- a/module/services/accounts/code/pkg/business/org_admin_continuity_test.go +++ b/module/services/accounts/code/pkg/business/org_admin_continuity_test.go @@ -113,6 +113,21 @@ func continuityService(t *testing.T, store business.Store) *business.Service { service, err := business.NewService(store) require.NoError(t, err) service.SetEntitlementChecker(business.NewDefaultEntitlementChecker(store)) + // A POLICY LOG, because RemoveOrgMember is now a witnessed narrowing. + // + // Without one it refuses outright — which is the fail-closed design working, + // and it moves this file's subject. These tests are about the + // ORGANISATION-SCOPED LOCK: that two concurrent removals serialise and the + // second is refused for emptying the organisation. On a host with no log + // both contenders refuse before reaching the lock, so "exactly one may + // commit" became zero and the lock was no longer being tested at all. + // + // The REAL postgres store is the local half, so the receipt still has to + // commit in the same transaction as the removal; only the external log is a + // double. That is the same wiring every other narrowing test in this + // package uses, and for the reason its comment gives — a service without a + // log is a configuration production is not meant to have. + wirePolicyLogForTests(service, testStore) return service } diff --git a/module/services/accounts/code/pkg/business/organization_lifecycle.go b/module/services/accounts/code/pkg/business/organization_lifecycle.go index 0aa152364..09ca21e00 100644 --- a/module/services/accounts/code/pkg/business/organization_lifecycle.go +++ b/module/services/accounts/code/pkg/business/organization_lifecycle.go @@ -151,55 +151,85 @@ func (s *Service) LeaveOrganization(ctx context.Context, userID string, req *gen // // Authorization (owner, or platform super administrator) is the handler's; the // typed slug confirmation is checked here, against the row under the lock. +// +// A NARROWING, and the widest one an organization has, so it runs under the +// policy log: the entry is appended to the external record and receipted BEFORE +// anything is revoked, and every revocation commits in the same transaction as +// the receipt's commit. A host that cannot witness the append refuses the +// deletion rather than performing one a restore could silently undo. +// +// ONE WITNESSED OPERATION, NOT ONE PER INSTALLATION — the decision this path +// forces, and deletedOrganizationPolicyLogEntry carries the reasoning. The +// short form: everything revoked here is revoked BECAUSE the organization was +// archived, so the archive is the fact worth recording and the organization is +// the subject a replay can ask about; an entry per installation would record +// that same fact several times. The alternative is also not available — +// WithPolicyLoggedNarrowing cannot nest (each call opens its own +// WithControlPlane transaction, and tenant_tx.go's no-nesting rule is that the +// second checks out a second pool connection while the first still holds one), +// so the per-installation entries could not be appended from inside this +// transaction at all. That is why the loop below calls uninstallSolutionTx — +// the same function an operator's uninstall applies inside ITS witnessed +// operation — rather than UninstallSolution, which would try to witness each +// installation separately. +// +// The transaction is the policy log's control-plane one rather than this +// organization's, because the receipt lives in a control-plane relation +// app_tenant holds no grant on, and the receipt and the revocations have to be +// one transaction or "applied but unwitnessed" becomes reachable. The +// organization's own scoping is not lost: every statement below names req.OrgId +// explicitly, which is what the tenant policy would have checked. func (s *Service) DeleteOrganization(ctx context.Context, actorID string, req *gen.DeleteOrganizationRequest) error { w := wool.Get(ctx).In("DeleteOrganization") actorType := s.actorTypeForCreator(ctx, actorID) var members []*gen.OrgMembership - if err := s.store.WithOrgTx(ctx, req.OrgId, func(ctx context.Context) error { - if err := s.store.LockOrgAdministration(ctx, req.OrgId); err != nil { - return w.Wrapf(err, "cannot lock organization administration") - } - org, err := s.store.GetOrganization(ctx, req.OrgId) - if err != nil { - return err - } - if org == nil || org.ArchivedAt != nil { - return NewStoreError(fmt.Errorf("organization %s not found", req.OrgId), ErrTypeNotFound) - } - if !strings.EqualFold(strings.TrimSpace(req.ConfirmSlug), org.Slug) { - return ErrOrgDeleteConfirmation - } - if members, err = s.store.ListOrgMembers(ctx, req.OrgId); err != nil { - return err - } - if _, err := s.store.RevokeOrgAPIKeys(ctx, req.OrgId); err != nil { - return err - } - if _, err := s.store.RevokeOrgPendingInvitations(ctx, req.OrgId); err != nil { - return err - } - installations, err := s.store.ActiveInstallationIDs(ctx, req.OrgId) - if err != nil { - return err - } - for _, installationID := range installations { - if err := s.uninstallSolutionTx(ctx, actorID, actorType, req.OrgId, installationID); err != nil { - return w.Wrapf(err, "cannot uninstall %s", installationID) + if err := s.WithPolicyLoggedNarrowing(ctx, + deletedOrganizationPolicyLogEntry(actorID, req.OrgId), + func(ctx context.Context) error { + if err := s.store.LockOrgAdministration(ctx, req.OrgId); err != nil { + return w.Wrapf(err, "cannot lock organization administration") } - } - if err := s.revokeSourceDelegationsTx(ctx, actorID, SourceDelegationFilter{OrgID: req.OrgId}, SourceDelegationMemberRemoved); err != nil { - return err - } - if _, err := s.store.RemoveAllOrgMembers(ctx, req.OrgId); err != nil { - return err - } - if err := s.store.ArchiveOrganization(ctx, req.OrgId, actorID); err != nil { - return err - } - return s.emitTx(ctx, actorID, actorType, EventOrgDeleted, "organization", req.OrgId, req.OrgId, map[string]any{ - "slug": org.Slug, - }) - }); err != nil { + org, err := s.store.GetOrganization(ctx, req.OrgId) + if err != nil { + return err + } + if org == nil || org.ArchivedAt != nil { + return NewStoreError(fmt.Errorf("organization %s not found", req.OrgId), ErrTypeNotFound) + } + if !strings.EqualFold(strings.TrimSpace(req.ConfirmSlug), org.Slug) { + return ErrOrgDeleteConfirmation + } + if members, err = s.store.ListOrgMembers(ctx, req.OrgId); err != nil { + return err + } + if _, err := s.store.RevokeOrgAPIKeys(ctx, req.OrgId); err != nil { + return err + } + if _, err := s.store.RevokeOrgPendingInvitations(ctx, req.OrgId); err != nil { + return err + } + installations, err := s.store.ActiveInstallationIDs(ctx, req.OrgId) + if err != nil { + return err + } + for _, installationID := range installations { + if err := s.uninstallSolutionTx(ctx, actorID, actorType, req.OrgId, installationID); err != nil { + return w.Wrapf(err, "cannot uninstall %s", installationID) + } + } + if err := s.revokeSourceDelegationsTx(ctx, actorID, SourceDelegationFilter{OrgID: req.OrgId}, SourceDelegationMemberRemoved); err != nil { + return err + } + if _, err := s.store.RemoveAllOrgMembers(ctx, req.OrgId); err != nil { + return err + } + if err := s.store.ArchiveOrganization(ctx, req.OrgId, actorID); err != nil { + return err + } + return s.emitTx(ctx, actorID, actorType, EventOrgDeleted, "organization", req.OrgId, req.OrgId, map[string]any{ + "slug": org.Slug, + }) + }); err != nil { return w.Wrapf(err, "cannot delete organization") } for _, member := range members { diff --git a/module/services/accounts/code/pkg/business/organizations.go b/module/services/accounts/code/pkg/business/organizations.go index 05adc4cf1..71af5bfc4 100644 --- a/module/services/accounts/code/pkg/business/organizations.go +++ b/module/services/accounts/code/pkg/business/organizations.go @@ -386,10 +386,23 @@ func (s *Service) ConvergeFixtureOrgMember(ctx context.Context, req *gen.AddOrgM func (s *Service) RemoveOrgMember(ctx context.Context, actorID string, req *gen.RemoveOrgMemberRequest) error { w := wool.Get(ctx).In("RemoveOrgMember") + // A NARROWING — the member loses the standing every grant inside the + // organization hangs from — so it runs under the policy log: the entry is + // appended to the external record and receipted BEFORE the membership goes, + // and the removal commits in the same transaction as the receipt's commit. A + // host that cannot witness the append refuses rather than removing + // unwitnessed. + // // Lock, last-admin guard, dependent-access delete, membership delete, and - // the audit event all run inside one org-scoped WithOrgTx: org_members RLS - // + organizations RLS both let the queries through, and the record cannot - // commit describing a removal whose dependent access is still standing. + // the audit event all run inside that ONE transaction, so the record cannot + // commit describing a removal whose dependent access is still standing. It + // is the policy log's control-plane transaction rather than this + // organization's, because the receipt relation is control-plane only and the + // receipt and the removal have to be atomic. What the tenant policy used to + // confine is confined by the statements instead: every call below names + // req.OrgId — the continuity count, both locks, the dependent-membership + // delete, the membership delete and the delegation revoke — so none of them + // can reach another organization's rows. // // Two locks, in the order AUTHZ.md fixes. The organization-wide // administration lock is taken first, inside the continuity guard, because @@ -397,12 +410,14 @@ func (s *Service) RemoveOrgMember(ctx context.Context, actorID string, req *gen. // taken before the writes, so a concurrent team insert for the same pair // either commits before the dependent delete or waits behind this // transaction, and can neither be missed by it nor land after it. - if err := s.store.WithOrgTx(ctx, req.OrgId, func(ctx context.Context) error { - if err := s.removeOrgMembershipTx(ctx, actorID, req.OrgId, req.UserId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventOrgMemberRemoved, "organization", req.OrgId, req.OrgId) - }); err != nil { + if err := s.WithPolicyLoggedNarrowing(ctx, + removeOrgMemberPolicyLogEntry(actorID, req.OrgId, req.UserId), + func(ctx context.Context) error { + if err := s.removeOrgMembershipTx(ctx, actorID, req.OrgId, req.UserId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventOrgMemberRemoved, "organization", req.OrgId, req.OrgId) + }); err != nil { return w.Wrapf(err, "cannot remove member") } diff --git a/module/services/accounts/code/pkg/business/permissions.go b/module/services/accounts/code/pkg/business/permissions.go index 6e4fc2c8b..733004d83 100644 --- a/module/services/accounts/code/pkg/business/permissions.go +++ b/module/services/accounts/code/pkg/business/permissions.go @@ -115,25 +115,34 @@ func (s *Service) ListRoleAssignments(ctx context.Context, req *gen.ListRoleAssi return &gen.ListRoleAssignmentsResponse{Assignments: assignments}, nil } -// RevokeRole removes a role assignment. Org-scoped revocations go -// through WithOrgTx; platform-level revocations (req.OrgId == "") -// require platform_admin (handler-enforced) and run under bypass. +// RevokeRole removes a role assignment. +// +// A NARROWING, so it runs under the policy log: the entry is appended to the +// external record and receipted here BEFORE the assignment goes, and the delete +// commits in the same transaction as the receipt's commit. A host that cannot +// witness the append refuses rather than revoking unwitnessed — a revocation a +// restore could silently undo is worse than a refusal the caller can see. +// +// The transaction is the policy log's control-plane one for BOTH the +// organization-scoped and the platform-level revocation, where before this the +// first ran in its tenant transaction and the second under the control plane. +// The receipt relation is control-plane only and the two writes have to be +// atomic, so there is no choice of transaction left to make; what the tenant one +// used to check is checked by the statement instead, which names req.OrgId and +// matches it with IS NOT DISTINCT FROM — so a caller naming another +// organization's assignment deletes nothing, exactly as the tenant policy would +// have produced. func (s *Service) RevokeRole(ctx context.Context, actorID string, req *gen.RevokeRoleRequest) error { w := wool.Get(ctx).In("RevokeRole") - wrap := func(ctx context.Context) error { - if err := s.store.RevokeRole(ctx, req.SubjectId, req.RoleId, req.OrgId, req.Scope); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventRoleRevoked, "role", req.RoleId, req.OrgId) - } - var err error - if req.OrgId == "" { - err = s.store.WithControlPlane(ctx, wrap) - } else { - err = s.store.WithOrgTx(ctx, req.OrgId, wrap) - } - if err != nil { + if err := s.WithPolicyLoggedNarrowing(ctx, + revokeRolePolicyLogEntry(actorID, req.OrgId, req.SubjectId, req.RoleId, req.Scope), + func(ctx context.Context) error { + if err := s.store.RevokeRole(ctx, req.SubjectId, req.RoleId, req.OrgId, req.Scope); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventRoleRevoked, "role", req.RoleId, req.OrgId) + }); err != nil { return w.Wrapf(err, "cannot revoke role") } return nil diff --git a/module/services/accounts/code/pkg/business/platform_admin.go b/module/services/accounts/code/pkg/business/platform_admin.go index ae1a9e3e5..2a15736f9 100644 --- a/module/services/accounts/code/pkg/business/platform_admin.go +++ b/module/services/accounts/code/pkg/business/platform_admin.go @@ -489,6 +489,16 @@ func (s *Service) GrantPlatformRole(ctx context.Context, actorID string, req *ge } // RevokePlatformRole removes a user's platform admin status (super_admin only). +// +// The widest authority this host grants, so it is the narrowing the log exists +// for most: appended to the external record and receipted BEFORE the row goes, +// with the delete and the receipt's commit in one transaction. A host that +// cannot witness the append refuses — a platform role restored by a database +// restore is an administrator nobody revoked. +// +// The caller's own authorisation is checked BEFORE the append, so a refused +// request appends nothing: an entry for an operation that was never permitted +// would be a revocation in the record that did not happen. func (s *Service) RevokePlatformRole(ctx context.Context, actorID string, req *gen.RevokePlatformRoleRequest) error { w := wool.Get(ctx).In("RevokePlatformRole") @@ -496,12 +506,14 @@ func (s *Service) RevokePlatformRole(ctx context.Context, actorID string, req *g return w.Wrapf(err, "permission denied") } - if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - if err := s.store.RevokePlatformRole(ctx, req.UserId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventPlatformRoleRevoked, "user", req.UserId, "") - }); err != nil { + if err := s.WithPolicyLoggedNarrowing(ctx, + revokePlatformRolePolicyLogEntry(actorID, req.UserId), + func(ctx context.Context) error { + if err := s.store.RevokePlatformRole(ctx, req.UserId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventPlatformRoleRevoked, "user", req.UserId, "") + }); err != nil { return w.Wrapf(err, "cannot revoke platform role") } return nil diff --git a/module/services/accounts/code/pkg/business/platform_catalogue.go b/module/services/accounts/code/pkg/business/platform_catalogue.go index 43e42ab67..1684ee1f9 100644 --- a/module/services/accounts/code/pkg/business/platform_catalogue.go +++ b/module/services/accounts/code/pkg/business/platform_catalogue.go @@ -110,7 +110,7 @@ func (s *Service) ListPlatformCatalogue(ctx context.Context, actorID string, inc return nil, w.Wrapf(err, "cannot read the platform catalogue") } now := time.Now() - entries := projectPlatformCatalogue(s.modulePrincipals, registrations, installations, includeTombstoned, now) + entries := projectPlatformCatalogue(s.declaredModules(), registrations, installations, includeTombstoned, now) enforceCatalogueVerdictEvidence(ctx, entries, now) return &PlatformCatalogue{ Entries: entries, @@ -185,8 +185,30 @@ func projectPlatformCatalogue( solutions[name] = &PlatformCatalogueEntry{Entry: entry} return solutions[name] } + // An installation names the immutable solution TARGET, never a solution id + // and never a route alias — `Installation.solution_identifier` is gone with + // the runtime registration writer. The declared presence record is the only + // thing that maps a target back to the solution that opened it + // (SolutionDeclaredBinding.TargetID), so the index is built from the + // registrations before any installation is placed. + // + // A target the registry cannot resolve is keyed by the TARGET ID rather than + // forced into some solution's row. That keeps the entry honest — it reads as + // an installation of something not recorded here, which is what it is — and + // it is why this loop cannot run before the index exists. + solutionOfTarget := make(map[string]string, len(registrations)) + for _, registration := range registrations { + if declared := registration.Declared; declared != nil && declared.TargetID != "" { + solutionOfTarget[declared.TargetID] = registration.SolutionID + } + } for _, installation := range installations { - row := solution(installation.Installation.GetSolutionIdentifier()) + target := installation.Installation.GetTargetId() + name, resolved := solutionOfTarget[target] + if !resolved { + name = target + } + row := solution(name) row.Entry.Installations = append(row.Entry.Installations, catalogueInstallation(installation)) } for _, registration := range registrations { diff --git a/module/services/accounts/code/pkg/business/platform_catalogue_test.go b/module/services/accounts/code/pkg/business/platform_catalogue_test.go index d0a61e88c..245e629a7 100644 --- a/module/services/accounts/code/pkg/business/platform_catalogue_test.go +++ b/module/services/accounts/code/pkg/business/platform_catalogue_test.go @@ -320,7 +320,12 @@ func TestCatalogueReadBoundaryWithholdsUnsupportedAffirmativeVerdict(t *testing. func catalogueTestInstallation(id, solution, org, agent string) *CatalogueInstallationRecord { return &CatalogueInstallationRecord{ Installation: &gen.Installation{ - Id: id, OrgId: org + "-id", SolutionIdentifier: solution, + // An installation names the immutable solution TARGET, never a + // solution id: `solution_identifier` went with the runtime + // registration writer. The projection resolves target -> solution + // through the declared record, so a target with no declaring + // registration resolves to nothing — which is the `i3` case below. + Id: id, OrgId: org + "-id", TargetId: solution + "-target", Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, }, OrgName: org, @@ -350,11 +355,24 @@ func TestProjectPlatformCatalogue(t *testing.T) { ModulePrincipalID("zeta"): {Prefix: "zeta"}, ModulePrincipalID("alpha"): {Prefix: "alpha"}, } + // Each registration declares the target it opened. That is what maps an + // installation back to a solution now, so a registration without it is + // invisible to the installation loop. + declared := func(solution string) *SolutionDeclaredBinding { + return &SolutionDeclaredBinding{ + BindingID: "acme.test." + solution, Generation: 1, + Release: "acme/" + solution + "@1.0.0", TargetID: solution + "-target", + } + } registrations := []*SolutionRegistration{ - {SolutionID: "registered", Publisher: "solution:registered", Revision: 4}, - {SolutionID: "installed", Publisher: "solution:installed", Revision: 7}, - {SolutionID: "retired", Publisher: "solution:retired", Revision: 9, TombstonedAt: &now}, - {SolutionID: "retired-but-installed", Publisher: "solution:retired-but-installed", Revision: 11, TombstonedAt: &now}, + {SolutionID: "registered", Publisher: "solution:registered", Revision: 4, + Declared: declared("registered")}, + {SolutionID: "installed", Publisher: "solution:installed", Revision: 7, + Declared: declared("installed")}, + {SolutionID: "retired", Publisher: "solution:retired", Revision: 9, TombstonedAt: &now, + Declared: declared("retired")}, + {SolutionID: "retired-but-installed", Publisher: "solution:retired-but-installed", Revision: 11, + TombstonedAt: &now, Declared: declared("retired-but-installed")}, } installations := []*CatalogueInstallationRecord{ catalogueTestInstallation("i1", "installed", "Acme", "example/installed:1.2.0"), @@ -370,7 +388,11 @@ func TestProjectPlatformCatalogue(t *testing.T) { "CATALOGUE_ENTRY_KIND_SOLUTION:installed", "CATALOGUE_ENTRY_KIND_SOLUTION:registered", "CATALOGUE_ENTRY_KIND_SOLUTION:retired-but-installed", - "CATALOGUE_ENTRY_KIND_SOLUTION:unregistered", + // No registration declares this target, so the row is keyed by the + // TARGET rather than forced into some solution's. That keeps the + // entry honest: it is an installation of something this registry + // has no record of. + "CATALOGUE_ENTRY_KIND_SOLUTION:unregistered-target", }, catalogueEntryNames(entries), "modules first, then solutions, each by name; an uninstalled tombstone is omitted") byName := make(map[string]*PlatformCatalogueEntry) @@ -425,7 +447,7 @@ func TestProjectPlatformCatalogue(t *testing.T) { require.Equal(t, "Org team i1", first.GetInheritedTeams()[0].GetSubjectLabel(), "inherited reach is carried apart from what was granted here") require.Equal(t, "1.3.0", installed.Entry.GetInstallations()[1].GetAgentRelease().GetVersion()) - unregistered := byName["unregistered"] + unregistered := byName["unregistered-target"] require.Nil(t, unregistered.Registration) require.Equal(t, gen.CatalogueGapReason_CATALOGUE_GAP_REASON_NOT_RECORDED, unregistered.Entry.GetPublisherGap().GetReason()) require.Contains(t, unregistered.Entry.GetPublisherGap().GetDetail(), "No registration") diff --git a/module/services/accounts/code/pkg/business/policy_log.go b/module/services/accounts/code/pkg/business/policy_log.go new file mode 100644 index 000000000..b81bd99b4 --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log.go @@ -0,0 +1,480 @@ +package business + +import ( + "context" + "errors" + "fmt" + "time" +) + +// The policy log: append → receipt → commit-with-receipt, before anything +// narrows. +// +// WHY IT IS EXTERNAL. A transactional audit event plus an append-only table in +// the same database is not an external record of authority. Restoring that +// database restores revoked authority together with its own history, so the +// history cannot witness against the state — the two were backed up together. +// The log therefore lives elsewhere, and what this host keeps is the RECEIPT: +// proof that an entry reached the log BEFORE the narrowing it describes took +// effect here. +// +// THE DIRECTION OF THE ASYMMETRY IS THE DESIGN. Two failures are possible +// between the two writes, and they must not be treated alike: +// +// - append fails ⇒ nothing narrows. The caller is refused, and the state is +// exactly as before. Safe, because authority was not reduced and nobody was +// told it was. +// - append succeeds, commit fails ⇒ the LOG says the narrowing happened and +// this host has not applied it. That must fail CLOSED: every replica +// enforces the narrowing, or refuses to serve, until the gap is reconciled. +// The authority of record has spoken, and a host serving the wider authority +// it still holds locally is serving authority that was revoked. +// +// The reverse — commit without append — must be IMPOSSIBLE, which is why the +// append comes first and its receipt is required by the commit. +// +// WHAT COUNTS AS A NARROWING. Revocation, a tenancy change, a narrowed envelope +// — and, deliberately, AUTHORISED REGRANTS too. A log that recorded only +// reductions could not answer "was this principal's authority restored, or has +// it never been taken away?", and those have different answers to "may it act +// now". + +// PolicyLogSubjectKind is what a logged operation is about. The set is closed: +// an operation whose subject this host cannot name is one a reconciliation +// cannot act on, so there is no "other". +type PolicyLogSubjectKind string + +const ( + PolicyLogPrincipal PolicyLogSubjectKind = "principal" + PolicyLogBinding PolicyLogSubjectKind = "binding" + PolicyLogInstallation PolicyLogSubjectKind = "installation" + PolicyLogTeamMembership PolicyLogSubjectKind = "team_membership" + PolicyLogScopeGrant PolicyLogSubjectKind = "scope_grant" + + // PolicyLogOrganization is the organization itself, for the one narrowing + // whose subject is not a grant hanging off it: archiving it, which ends + // every standing authority into it at once. + // + // It is its own kind because both alternatives were wrong in the same + // direction. Reporting the archive as a `binding` whose id is an + // organization's sends a reconciliation looking for a binding that does + // not exist; one entry per revoked grant records the same fact many times + // and leaves a reader to work out which archive they belonged to — which + // is exactly the argument closedSolutionTargetPolicyLogEntry already makes + // for naming the target rather than each installation it revokes. + // + // The set is closed, so adding a kind is how a new SHAPE of narrowing is + // admitted. That is what the closure is for: it forces "what can a replay + // act on" to be answered once, here, instead of per call site. + PolicyLogOrganization PolicyLogSubjectKind = "organization" +) + +// Valid reports whether a kind is one a reconciliation can act on. +func (kind PolicyLogSubjectKind) Valid() bool { + switch kind { + case PolicyLogPrincipal, PolicyLogBinding, PolicyLogInstallation, + PolicyLogTeamMembership, PolicyLogScopeGrant, PolicyLogOrganization: + return true + } + return false +} + +// PolicyLogDecision is what the operation did to the subject's authority. +type PolicyLogDecision string + +const ( + // PolicyLogNarrowed reduced it — a revocation, a tenancy change, a tighter + // envelope. + PolicyLogNarrowed PolicyLogDecision = "narrowed" + // PolicyLogRegranted restored or widened it, by an authorised act. + // + // Logged for the same reason a narrowing is: without it the log cannot tell + // "this authority was restored" from "it was never taken away", and those + // have different answers to whether the subject may act now. + PolicyLogRegranted PolicyLogDecision = "regranted" +) + +// PolicyLogEntry is one operation offered to the log. +type PolicyLogEntry struct { + // OperationID is the idempotency key, and it is the CALLER'S. A retry of the + // same logical operation must append once however many times it is + // attempted, and only the caller knows that two attempts are the same + // operation. Generating it here would make every retry a new entry, and a + // log with two entries for one revocation cannot be replayed into a single + // answer. + OperationID string + + Decision PolicyLogDecision + SubjectKind PolicyLogSubjectKind + SubjectID string + + // Policy is the authority as it stands AFTER the operation. The log holds + // the resulting state rather than a diff, because a replay has to be able to + // answer "what is this subject's authority" from one entry rather than by + // folding every entry since the beginning. + Policy map[string]any + + // Supersedes is the operation this one replaces, when it replaces one. + Supersedes string + // EnvelopeRevision is the ceiling the operation was decided against, so a + // replay can tell an operation made under an older envelope from one made + // under the current. + EnvelopeRevision uint64 + + // Actor is who performed it. Required: an authority change with no actor is + // a change nobody can be asked about. + Actor string +} + +// Validate refuses an entry the log could not be replayed from. +func (entry *PolicyLogEntry) Validate() error { + switch { + case entry == nil: + return errors.New("policy log entry is absent") + case entry.OperationID == "": + return errors.New("policy log entry has no operation id, so a retry of it could not be recognised as the same operation") + case entry.Decision != PolicyLogNarrowed && entry.Decision != PolicyLogRegranted: + return fmt.Errorf("policy log entry has decision %q, want %q or %q", + entry.Decision, PolicyLogNarrowed, PolicyLogRegranted) + case !entry.SubjectKind.Valid(): + return fmt.Errorf("policy log entry has subject kind %q, which no reconciliation could act on", entry.SubjectKind) + case entry.SubjectID == "": + return errors.New("policy log entry names no subject") + case entry.Actor == "": + return errors.New("policy log entry names no actor, so the authority change could not be attributed") + } + return nil +} + +// PolicyLogReceipt is the log's acknowledgement of one appended entry. +type PolicyLogReceipt struct { + // Receipt is the log's own token for the entry. Opaque to this host, and + // stored verbatim: its presence is what separates "the log witnessed this" + // from "this host decided it". + Receipt string + // Sequence is the log's monotonic position for the entry, which the host's + // cursor is compared against. + Sequence uint64 + // AppendedAt is when the log recorded it, by the LOG'S clock rather than + // this host's. A host comparing its own clock against a log's ordering would + // be comparing two unrelated things. + AppendedAt time.Time +} + +// ErrPolicyLogUnreachable reports that the log could not be appended to or read. +// +// It is the reason the host refuses to serve. Distinct from a refusal by the log +// because the two have opposite meanings: a log that said no has made a +// decision, and a log that could not be reached has made none — and a host that +// treated the second as the first would serve while believing it had checked. +var ErrPolicyLogUnreachable = errors.New("policy log is unreachable") + +// ErrPolicyLogUnreconciled reports that the log holds an appended operation this +// host has not applied. +// +// This is the append-ok/commit-fail state, and it is a SERVING condition rather +// than an error on one request: the authority of record says a narrowing +// happened, so every replica must enforce it or stop serving until the gap is +// closed. +var ErrPolicyLogUnreconciled = errors.New("policy log holds an operation this host has not applied") + +// PolicyLog is the external append-only authority record. +type PolicyLog interface { + // Append records one entry and returns its receipt. + // + // It must be IDEMPOTENT on the entry's operation id: a retry returns the + // original receipt rather than appending again. That requirement is on the + // log rather than on this host because only the log can make it atomic — + // a host checking "did I already append?" before appending has a window + // between the two. + Append(ctx context.Context, entry *PolicyLogEntry) (*PolicyLogReceipt, error) + + // Entries returns every entry after a sequence, in order. + // + // Used by reconciliation, never on a request path. The reader deliberately + // holds no job-creation permission on the log's warehouse, so this is a + // bounded row read with a restriction rather than a query. + Entries(ctx context.Context, afterSequence uint64, limit int) ([]*PolicyLogRecord, error) +} + +// PolicyLogRecord is one entry as the log returns it. +type PolicyLogRecord struct { + PolicyLogEntry + Receipt string + Sequence uint64 + LoggedAt time.Time +} + +// PolicyLogStore is the host's local half: the receipts and the cursor. +type PolicyLogStore interface { + // RecordPolicyLogAppend writes the receipt for an appended operation, with + // no commit yet. Reports whether the row was new, so a retry can tell an + // append it has already recorded from one it has not. + RecordPolicyLogAppend(ctx context.Context, receipt *PolicyLogReceipt, entry *PolicyLogEntry) (bool, error) + + // CommitPolicyLogOperation marks an appended operation applied. It runs in + // the SAME transaction as the narrowing it describes, which is what makes + // "applied but not committed" impossible — the only reachable gap is + // "appended but not applied", which fails closed. + CommitPolicyLogOperation(ctx context.Context, operationID string, at time.Time) error + + // UncommittedPolicyLogOperations returns appended operations with no commit. + UncommittedPolicyLogOperations(ctx context.Context) ([]*PolicyLogGap, error) + + // PolicyLogCursorState reads how far this host has reconciled, and when it + // last reached the log. + PolicyLogCursorState(ctx context.Context) (sequence uint64, reachedAt *time.Time, err error) + + // AdvancePolicyLogCursor records that the log was reached and read to a + // sequence. + AdvancePolicyLogCursor(ctx context.Context, sequence uint64, at time.Time) error +} + +// PolicyLogGap is an appended operation this host has not applied. +type PolicyLogGap struct { + OperationID string + Sequence uint64 + SubjectKind PolicyLogSubjectKind + SubjectID string + AppendedAt time.Time +} + +// PolicyLogDeadline bounds how long an append may take. +// +// It exists because the alternative is lock accumulation, not slowness. The +// append happens before the narrowing's transaction opens — deliberately, so a +// slow log cannot hold database locks — but a caller blocked indefinitely on the +// log still holds its request, its connection and whatever the caller above it +// holds. A bounded wait converts that into a refusal the caller can act on. +// +// Five seconds: long enough that an ordinary warehouse append succeeds, short +// enough that an authority change fails visibly rather than hanging. A narrowing +// that cannot be logged must not proceed, so the refusal is the correct outcome +// and not a degraded one. +const PolicyLogDeadline = 5 * time.Second + +// PolicyLogStaleAfter is how long a host may serve without having reached the +// log. +// +// Not zero, deliberately: a log round trip on every request would make the log's +// availability the host's own, and a momentary blip would become an outage. Not +// unbounded either, because then "I cannot reach the log" would never become "I +// must stop serving". One minute is the window in which a narrowing that has +// been appended but not seen here could still be honoured by this host's local +// state; past it, the host no longer knows whether its authority is current. +const PolicyLogStaleAfter = time.Minute + +// WithPolicyLoggedNarrowing runs a narrowing under the protocol. +// +// The sequence, and why each step is where it is: +// +// 1. VALIDATE the entry. An entry the log could not be replayed from must not +// reach the log, because a bad entry that is accepted is worse than a +// refused operation: it is a gap nothing can close. +// 2. APPEND, under a bounded deadline, BEFORE any transaction opens. A slow log +// then costs a refusal rather than held locks. +// 3. RECORD the receipt locally. From this instant the operation is a GAP, and +// every replica will enforce it or refuse until it is committed. +// 4. APPLY the narrowing and COMMIT the receipt in ONE transaction. Either both +// land or neither does, so "applied but unwitnessed" cannot exist. +// +// A failure at (4) leaves the gap from (3), which is the fail-closed state: the +// log says the narrowing happened, so the host enforces it or stops serving. +func (s *Service) WithPolicyLoggedNarrowing( + ctx context.Context, entry *PolicyLogEntry, apply func(ctx context.Context) error, +) error { + if err := entry.Validate(); err != nil { + return err + } + if s.policyLog == nil || s.policyLogStore == nil { + // Fail closed. A host with no log cannot narrow authority, because the + // narrowing would be unwitnessed and a restore would undo it silently. + return fmt.Errorf("%w: this host has no policy log, so authority cannot be narrowed", ErrPolicyLogUnreachable) + } + + // The deadline wraps the append alone. Putting it around the whole operation + // would make a slow DATABASE look like an unreachable log, and those have + // different answers. + appendCtx, cancel := context.WithTimeout(ctx, PolicyLogDeadline) + defer cancel() + + receipt, err := s.policyLog.Append(appendCtx, entry) + if err != nil { + return fmt.Errorf("%w: %w", ErrPolicyLogUnreachable, err) + } + if receipt == nil || receipt.Receipt == "" || receipt.Sequence == 0 { + // A log that returns no receipt has not witnessed anything, whatever it + // returned. Refusing here rather than storing an empty receipt keeps + // "witnessed" and "attempted" distinguishable. + return fmt.Errorf("%w: the log returned no usable receipt for operation %q", + ErrPolicyLogUnreachable, entry.OperationID) + } + + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + _, recordErr := s.policyLogStore.RecordPolicyLogAppend(ctx, receipt, entry) + return recordErr + }); err != nil { + // The gate's cached answer is dropped either way: the append landed, + // so a gap exists whether or not this host recorded it. + s.policyServing.invalidate() + // The append landed and this host could not even record that it did. + // The gap exists in the LOG, which reconciliation reads — so this is + // reported rather than swallowed, and the next reconciliation pass finds + // it there. + return fmt.Errorf("record policy log append for operation %q (the append LANDED; reconciliation will find it): %w", + entry.OperationID, err) + } + + // From here the operation is a GAP, and every replica reading this + // host's commit relation sees it. Drop the gate's cached answer so this + // host's own next request re-reads rather than serving the decision it + // made before the gap existed — and drop it again after the commit, so a + // gap that just closed does not keep the host shut for the TTL. + s.policyServing.invalidate() + defer s.policyServing.invalidate() + + // The narrowing and its receipt in one transaction. + return s.store.WithControlPlane(ctx, func(ctx context.Context) error { + if err := apply(ctx); err != nil { + return err + } + return s.policyLogStore.CommitPolicyLogOperation(ctx, entry.OperationID, s.policyNow()) + }) +} + +// PolicyLogServingState is why a host may not be serving. +type PolicyLogServingState struct { + // Unreachable is set when the log has not been reached within + // PolicyLogStaleAfter. + Unreachable bool + // Gaps are appended operations this host has not applied. + Gaps []*PolicyLogGap + // LastReachedAt is when the log was last read, nil if never. + LastReachedAt *time.Time +} + +// MayServe reports whether the host may serve, and why not when it may not. +// +// Two conditions, and both are refusals rather than degradations: +// +// - the log has not been reached within the staleness window. The host does +// not know whether its authority is current, and serving what it last +// believed is serving authority that may have been revoked. +// - there is an appended operation this host has not applied. The authority of +// record says a narrowing happened; serving the wider authority still held +// locally is serving authority that was revoked. +// +// Checked on the serving path and NOT only at startup, which is the requirement +// that makes it useful: a gap opens at runtime, when a commit fails after an +// append landed, and a startup-only check would serve through exactly the window +// the protocol exists for. +func (s *Service) MayServe(ctx context.Context) (bool, *PolicyLogServingState, error) { + if s.policyLogStore == nil { + // No log configured: nothing has been narrowed through the protocol, so + // there is nothing unreconciled. Serving is allowed, and the host's + // inability to narrow authority is reported by + // WithPolicyLoggedNarrowing rather than by refusing every request. + return true, &PolicyLogServingState{}, nil + } + state := &PolicyLogServingState{} + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + _, reachedAt, err := s.policyLogStore.PolicyLogCursorState(ctx) + if err != nil { + return err + } + state.LastReachedAt = reachedAt + gaps, err := s.policyLogStore.UncommittedPolicyLogOperations(ctx) + if err != nil { + return err + } + state.Gaps = gaps + return nil + }); err != nil { + return false, nil, err + } + if state.LastReachedAt == nil || s.policyNow().Sub(*state.LastReachedAt) > PolicyLogStaleAfter { + state.Unreachable = true + } + return !state.Unreachable && len(state.Gaps) == 0, state, nil +} + +// ReconcilePolicyLog reads the log forward and closes what it can. +// +// It runs on an interval, not only at startup. A gap opens at RUNTIME — a commit +// that failed after its append landed — and the requirement is that every +// replica enforces or refuses until reconciled, which a startup-only pass cannot +// provide. +// +// What it does NOT do is decide authority from the log. The log is the record; +// applying an entry means re-running the narrowing it describes, which is the +// caller's domain logic and not this function's. So reconciliation reports the +// gaps and advances the cursor, and the serving gate keeps the host closed until +// something has closed them. That division is deliberate: a reconciler that +// re-derived authority from log payloads would be a second implementation of +// every narrowing, and the copy is what drifts. +func (s *Service) ReconcilePolicyLog(ctx context.Context) (*PolicyLogServingState, error) { + if s.policyLog == nil || s.policyLogStore == nil { + return &PolicyLogServingState{}, nil + } + readCtx, cancel := context.WithTimeout(ctx, PolicyLogDeadline) + defer cancel() + + var cursor uint64 + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + cursor, _, err = s.policyLogStore.PolicyLogCursorState(ctx) + return err + }); err != nil { + return nil, err + } + + records, err := s.policyLog.Entries(readCtx, cursor, policyLogReadBatch) + if err != nil { + // Unreachable. The cursor is NOT advanced, so the staleness window + // closes and the host stops serving — which is the point: a host that + // cannot read the log does not know whether its authority is current. + return nil, fmt.Errorf("%w: %w", ErrPolicyLogUnreachable, err) + } + + now := s.policyNow() + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + highest := cursor + for _, record := range records { + // An entry this host has never seen is recorded as a GAP before the + // cursor passes it. Recording first is what makes the cursor safe to + // advance: if the pass dies here, the entries already written are + // gaps the serving gate sees, and the cursor still points below them. + if _, err := s.policyLogStore.RecordPolicyLogAppend(ctx, &PolicyLogReceipt{ + Receipt: record.Receipt, + Sequence: record.Sequence, + AppendedAt: record.LoggedAt, + }, &record.PolicyLogEntry); err != nil { + return err + } + if record.Sequence > highest { + highest = record.Sequence + } + } + return s.policyLogStore.AdvancePolicyLogCursor(ctx, highest, now) + }); err != nil { + return nil, err + } + + _, state, err := s.MayServe(ctx) + return state, err +} + +// policyLogReadBatch bounds one reconciliation read. Bounded because the reader +// holds no job-creation permission on the log's warehouse — this is a row read +// with a restriction, and an unbounded one would be a scan. +const policyLogReadBatch = 500 + +// policyNow is the host's clock, overridable in tests. Separate from the log's +// clock on purpose: receipts carry the LOG'S time, and comparing a host's clock +// against a log's ordering would compare two unrelated things. +func (s *Service) policyNow() time.Time { + if s.policyClock != nil { + return s.policyClock() + } + return time.Now().UTC() +} diff --git a/module/services/accounts/code/pkg/business/policy_log_gate.go b/module/services/accounts/code/pkg/business/policy_log_gate.go new file mode 100644 index 000000000..ad1f4a406 --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_gate.go @@ -0,0 +1,208 @@ +package business + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The serving gate: MayServe, on the request path. +// +// WHY NOT AT STARTUP. The state the gate refuses on OPENS AT RUNTIME. A commit +// that fails after its append landed leaves the log saying a narrowing happened +// and this host not having applied it, and a host that only checked at boot +// would serve the wider authority it still holds locally for exactly as long as +// that gap stays open — which is the window the whole protocol exists for. The +// same is true of the staleness condition: a log reachable at boot and +// unreachable an hour later must stop the host, and only a check on the request +// path can notice. +// +// WHY A REFUSAL AND NOT A DEGRADATION. Both conditions mean the same thing: this +// host does not know that the authority it is about to apply is current. Serving +// a narrower answer is not available — the host cannot compute one without the +// log — so the honest answer is to refuse the request. It is an OUTAGE, not a +// denial: the caller is not unauthorized, the host is unable, so the code is +// Unavailable and never PermissionDenied. A host that answered "forbidden" here +// would tell every caller their authority had been revoked when nothing had been +// revoked at all. + +// policyLogServingCacheFor is how long one serving answer is reused. +// +// Not zero: MayServe is a control-plane transaction, and one per request would +// put a cross-tenant round trip in front of every call the host serves — a cost +// the gate cannot justify when the answer changes only when a gap opens or the +// log is reached. +// +// Not longer either, and the bound is about what the cache can HIDE. Two things +// change the answer. A gap this host opens itself does not wait for the TTL at +// all: WithPolicyLoggedNarrowing invalidates the cache the instant it records an +// append, so the very next request re-reads. A gap another replica opened +// arrives here through reconciliation, whose interval is longer than this +// window, so the TTL adds nothing to that delay. One second is therefore the +// window in which the cache can hide nothing that is not already pending. +const policyLogServingCacheFor = time.Second + +// policyLogServingCache is the serving gate's memory of its last answer. +// +// ONLY A PERMISSION IS REMEMBERED, never a refusal, and the asymmetry is the +// point. A remembered permission can be wrong for at most the window below, and +// the two things that could make it wrong are both accounted for: a gap this +// host opens itself raises the dirty flag at the instant the append is recorded, +// and a gap another replica opened arrives through reconciliation, whose +// interval is longer than the window. A remembered REFUSAL has no such bound — +// a gap closed by an operator, or by whatever re-ran the narrowing, is closed in +// the database and nothing tells this process — so a cached refusal would keep +// the host shut after the reason had gone. Re-reading while refusing costs +// nothing that matters: a host in that state is serving no traffic to spend the +// read on. +type policyLogServingCache struct { + mu sync.Mutex + at time.Time + state *PolicyLogServingState + dirty atomic.Bool +} + +// invalidate discards the cached answer. Called by WithPolicyLoggedNarrowing +// around the commit, so the gap it may have just opened — and the gap it may +// have just closed — are both read fresh. +func (c *policyLogServingCache) invalidate() { + if c == nil { + return + } + c.dirty.Store(true) +} + +// ErrNotServing reports that the policy log forbids this host from serving. +// +// It wraps the specific condition, so a caller that cares can tell an +// unreconciled gap from an unreachable log; a caller that does not gets one +// sentinel to test. +var ErrNotServing = errors.New("this host is not serving") + +// RequireServing refuses when the policy log says this host must not serve. +// +// Called on the REQUEST PATH, by every transport's authorization interceptor, +// and deliberately before the handler rather than inside it: a request that +// reaches a handler has already been authorized against local state the host +// may no longer be entitled to use. +// +// A failure to EVALUATE the gate is also a refusal. If the control-plane read +// errors, the host does not know whether it holds a gap, and "I could not check" +// must not be served as "there is nothing to find" — that is the one substitution +// that turns a fail-closed protocol into a fail-open one. +func (s *Service) RequireServing(ctx context.Context) error { + ok, state, err := s.mayServeCached(ctx) + if err != nil { + return status.Error(codes.Unavailable, fmt.Sprintf( + "this host cannot determine whether its authority is current: %v", err)) + } + if ok { + return nil + } + return status.Error(codes.Unavailable, notServingReason(state)) +} + +// notServingReason says which condition closed the gate, without naming the +// subjects of the gaps: a refusal is served to an unauthenticated caller as +// readily as to an administrator, and the identities whose authority was +// narrowed are not a detail that belongs in it. The operation ids are in the +// local relation for an operator who can read it. +func notServingReason(state *PolicyLogServingState) string { + switch { + case state == nil: + return "this host is not serving: the policy log state is unknown" + case state.Unreachable && len(state.Gaps) > 0: + return fmt.Sprintf("this host is not serving: the policy log has not been reached, "+ + "and %d logged narrowing(s) are not applied here", len(state.Gaps)) + case state.Unreachable: + return "this host is not serving: the policy log has not been reached within the staleness " + + "window, so this host does not know whether its authority is current" + case len(state.Gaps) > 0: + return fmt.Sprintf("this host is not serving: %d narrowing(s) the policy log has witnessed "+ + "are not applied here", len(state.Gaps)) + default: + return "this host is not serving" + } +} + +// mayServeCached is MayServe with the bounded reuse described above. The cached +// answer is dropped when this host's own append raised the dirty flag, and the +// flag is cleared only after a fresh evaluation has replaced the answer — so a +// concurrent request cannot clear it and then serve the stale decision. +func (s *Service) mayServeCached(ctx context.Context) (bool, *PolicyLogServingState, error) { + cache := &s.policyServing + now := s.policyNow() + + cache.mu.Lock() + // cache.state is set only when the answer was "may serve", so a hit is a + // remembered permission by construction and a refusal is never reused. + fresh := cache.state != nil && !cache.dirty.Load() && + now.Sub(cache.at) >= 0 && now.Sub(cache.at) < policyLogServingCacheFor + if fresh { + state := cache.state + cache.mu.Unlock() + return true, state, nil + } + cache.mu.Unlock() + + ok, state, err := s.MayServe(ctx) + if err != nil { + return false, nil, err + } + + cache.mu.Lock() + if ok { + cache.at, cache.state = now, state + cache.dirty.Store(false) + } else { + cache.state = nil + } + cache.mu.Unlock() + return ok, state, nil +} + +// ReconcilePolicyLogEvery runs reconciliation on an interval until ctx is done. +// +// On an interval and not only at startup, for the same reason the gate is on the +// request path: the two facts it maintains both change while the host runs. It +// refreshes `reached_at`, which is what keeps the staleness window open — so a +// host whose reconciliation loop dies stops serving, which is the correct +// consequence of no longer knowing the log's state. And it records entries other +// replicas appended, so a narrowing one replica logged and failed to apply +// closes every replica's gate rather than only its own. +// +// A failed pass is not fatal and is not retried faster. The cursor is not +// advanced on failure, so the staleness window closes on its own and the gate +// does the refusing; a tighter retry loop would only hammer an unreachable log. +func (s *Service) ReconcilePolicyLogEvery(ctx context.Context, every time.Duration, onError func(error)) { + if s.policyLog == nil || s.policyLogStore == nil { + // Nothing is reconcilable, and the loop would be a ticker that reports + // an empty state forever. + return + } + ticker := time.NewTicker(every) + defer ticker.Stop() + for { + if _, err := s.ReconcilePolicyLog(ctx); err != nil && onError != nil { + onError(err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// PolicyLogReconcileInterval is how often the loop above runs. +// +// Shorter than PolicyLogStaleAfter, and that relationship is the point rather +// than the number: a host must get several chances to reach the log before the +// staleness window closes, or a single slow pass would take it out of service. +const PolicyLogReconcileInterval = 15 * time.Second diff --git a/module/services/accounts/code/pkg/business/policy_log_narrowings.go b/module/services/accounts/code/pkg/business/policy_log_narrowings.go new file mode 100644 index 000000000..2ced76cab --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_narrowings.go @@ -0,0 +1,391 @@ +package business + +import "fmt" + +// The narrowings that go through the policy log, and how each one names itself +// to it. +// +// Every function here builds one PolicyLogEntry. None of them performs the +// narrowing: the call sites wrap their own domain logic in +// WithPolicyLoggedNarrowing with the entry built here, so there is exactly one +// description of each operation and it is the one the log receives. +// +// WHY THE OPERATION ID IS FRESH ON EVERY ATTEMPT, and why that is the safe +// direction. The protocol wants the CALLER'S idempotency key, because only the +// caller knows two attempts are the same revocation. No RPC behind these +// entries carries one — there is no such field on the request messages and no +// ambient request key to derive one from — so this host genuinely cannot +// recognise a retry. The two ways to cope are not symmetric: +// +// - derive the id from the operation's inputs. A retry then collides and +// appends once, which is what the protocol asks for. But a narrowing that +// is reachable AGAIN after an authorised regrant — revoke, re-grant, revoke +// — derives the SAME id the second time, the log returns the first +// receipt, the local commit row is already committed, and the second +// revocation applies with nothing having witnessed it. That is an +// unwitnessed narrowing, the one outcome the protocol forbids outright. +// - a fresh id per attempt. A retry appends twice, so the log over-records. +// Harmless: an entry carries the authority as it stands AFTER the operation +// rather than a diff, so two entries for one revocation replay to the same +// answer, and the warehouse's replay view collapses per operation id. +// +// Over-recording is noise; under-recording is a restore that silently undoes a +// revocation. So the id is fresh, and the real fix — an idempotency key on the +// request messages, carried from the caller to here — is a contract change +// these entries will take the moment it exists. +func newPolicyLogOperationID(operation string) string { + return operation + "/" + NewIDString() +} + +// uninstallPolicyLogEntry describes revoking an installation: the agent +// principal loses its standing grant and the installation becomes revoked. +func uninstallPolicyLogEntry(actorID, orgID, installationID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("uninstall-solution"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogInstallation, + SubjectID: installationID, + Policy: map[string]any{ + "org_id": orgID, + "status": "revoked", + }, + Actor: actorID, + } +} + +// closedSolutionTargetPolicyLogEntry describes a withdrawn presence: the target +// is closed and every active installation naming it is revoked with it. +// +// The subject is the TARGET rather than each installation it revokes. The target +// is the identity whose consent period ended, and it is the one a replay can ask +// about: an installation revoked by a close is revoked because the target +// closed, so an entry per installation would record the same fact several times +// and leave a reader to work out which close they belonged to. +// +// The operation id is passed IN rather than generated here, because the apply +// transaction has to recognise the close it was appended for: the id is the +// witness reconcileSolutionTarget requires, and a second id generated here +// would leave the two halves naming different operations. +func closedSolutionTargetPolicyLogEntry( + operationID, targetID, bindingID, solutionID string, generation uint64, +) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: operationID, + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogInstallation, + SubjectID: targetID, + Policy: map[string]any{ + "binding_id": bindingID, + "solution_id": solutionID, + "closed_generation": generation, + "status": "closed", + "installations": "revoked", + }, + // The generation the withdrawal was decided against, so a replay can + // tell a close made under an older delivery from one under the current. + EnvelopeRevision: generation, + Actor: "solution:" + solutionID, + } +} + +// revokeScopePolicyLogEntry describes removing one hierarchical scope grant. +// +// The subject id spells the grant out rather than naming a row id, because the +// revocation itself is addressed that way: scope_grants is deleted by +// (org, subject, kind, path, role) and has no id the caller holds. A replay +// therefore reads the same key the narrowing used. +func revokeScopePolicyLogEntry(actorID, orgID, subjectID, subjectKind, scopePath, roleID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-scope"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogScopeGrant, + SubjectID: fmt.Sprintf("%s/%s/%s/%s/%s", + orgID, subjectKind, subjectID, scopePath, roleID), + Policy: map[string]any{ + "org_id": orgID, + "subject_id": subjectID, + "subject_kind": subjectKind, + "scope_path": scopePath, + "role_id": roleID, + "status": "revoked", + }, + Actor: actorID, + } +} + +// removeTeamMemberPolicyLogEntry describes removing a membership. A team is a +// subject permissions are granted to, so losing a membership narrows what the +// removed user may do through it. +func removeTeamMemberPolicyLogEntry(actorID, orgID, teamID, userID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("remove-team-member"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogTeamMembership, + SubjectID: teamID + "/" + userID, + Policy: map[string]any{ + "org_id": orgID, + "team_id": teamID, + "user_id": userID, + "status": "removed", + }, + Actor: actorID, + } +} + +// --------------------------------------------------------------------------- +// How a narrowing names its subject +// +// PolicyLogSubjectKind is a closed set — an operation whose subject this host +// cannot name is one a reconciliation cannot act on — so every entry below +// reports itself as one of the kinds that set admits. Which one is a uniform +// rule rather than per-call-site taste, because a reader comparing two entries +// has to be able to assume the kinds mean the same thing in both: +// +// - PolicyLogScopeGrant — a GRANT ROW addressed by its tuple: a hierarchical +// scope grant, a role assignment, a per-record share. Each one is "this +// role, to this subject, over this thing", and each is revoked by naming +// the tuple rather than a row id the caller holds, so the subject id +// spells the tuple out and a replay reads the same key the revocation used. +// - PolicyLogBinding — the STANDING AUTHORITY a subject holds in an estate, +// which is what grants hang from rather than a grant itself: an +// organization membership, a platform role, a source delegation. +// - PolicyLogPrincipal — the ACTING IDENTITY itself. Nothing about what some +// subject may do is narrowed; an actor stops being able to act at all. +// - PolicyLogInstallation, PolicyLogTeamMembership — as the entries above +// this block already use them. +// +// LABELLED STOPGAP, and the fix belongs in policy_log.go. An organization is a +// subject this host narrows — archiving one revokes every authority inside it +// — and the closed set has no kind that names it. deletedOrganizationPolicyLog +// Entry below therefore reports PolicyLogBinding, which is the nearest true +// statement (the archive's whole effect is that every standing binding into the +// organization is gone) but not the exact one: a reconciliation reading that +// entry looks for a binding whose id is an organization's. The fix is a +// PolicyLogOrganization constant in the closed set, and this entry takes it the +// moment it exists. + +// deletedOrganizationPolicyLogEntry describes archiving an organization: its +// API keys, pending invitations, installations, source delegations and every +// membership are revoked together, and with no membership left every +// request-path authorization refuses the organization. +// +// ONE ENTRY, NOT ONE PER REVOCATION, and the reason is the one +// closedSolutionTargetPolicyLogEntry gives: everything this operation revokes +// is revoked BECAUSE the organization was archived, so an entry per +// installation — or per membership, or per key — would record the same fact +// several times and leave a reader to work out which archive they belonged to. +// The organization is the identity whose authority ended, and it is the one a +// replay can ask about. +// +// It also could not be done the other way round. The per-installation entries +// would have to be appended from inside the archive's own transaction, and +// WithPolicyLoggedNarrowing cannot nest: each call opens its own +// WithControlPlane transaction, and tenant_tx.go's no-nesting rule is that a +// second transaction checks out a second pool connection while the first still +// holds one. So the archive revokes its installations through +// uninstallSolutionTx — the same function an operator's uninstall applies — +// under this one witnessed operation, rather than through UninstallSolution, +// which would try to witness each of them separately. +// +// Policy names what the archive revoked rather than listing the rows it +// touched: the ids would have to be read before the append, outside the +// transaction that does the work, and a list read then is a list that may have +// changed by the time the archive runs. What is true after the operation is +// that none of them is live, which is what an entry is for. +func deletedOrganizationPolicyLogEntry(actorID, orgID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("delete-organization"), + Decision: PolicyLogNarrowed, + // The organization itself. This reported PolicyLogBinding while the + // closed set had no kind that named an organization — a labelled + // stopgap, because an archive is not a binding and a reconciliation + // reading it would look for a binding whose id is an organization's. + // PolicyLogOrganization now exists, so the entry says what it means. + SubjectKind: PolicyLogOrganization, + SubjectID: orgID, + Policy: map[string]any{ + "org_id": orgID, + "status": "archived", + "memberships": "removed", + "api_keys": "revoked", + "pending_invitations": "revoked", + "installations": "revoked", + "source_delegations": "revoked", + }, + Actor: actorID, + } +} + +// revokeRolePolicyLogEntry describes removing one role assignment: the subject +// stops holding that role, in that organization, at that scope. +// +// The subject id spells the assignment out rather than naming a row id, for the +// reason revokeScopePolicyLogEntry does: role_assignments is revoked by +// (subject, role, org, scope) and the caller holds no id for the row. An empty +// org is a PLATFORM-level assignment and an empty scope is the organization +// whole, and both are spelled as the empty segment they are — the store matches +// them with IS NOT DISTINCT FROM, so "" and "no org" are the same key there and +// must stay the same key here. +func revokeRolePolicyLogEntry(actorID, orgID, subjectID, roleID, scope string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-role"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogScopeGrant, + SubjectID: fmt.Sprintf("%s/%s/%s/%s", orgID, subjectID, roleID, scope), + Policy: map[string]any{ + "org_id": orgID, + "subject_id": subjectID, + "role_id": roleID, + "scope": scope, + "status": "revoked", + }, + Actor: actorID, + } +} + +// revokeSharePolicyLogEntry describes removing one per-record share: the +// subject stops holding that role on that one record. +// +// The subject id spells the share out for the same reason the two entries above +// do — record_shares is deleted by (org, resource type, resource, subject, +// subject kind, role) and has no id the caller holds. +func revokeSharePolicyLogEntry( + actorID, orgID, resourceType, resourceID, subjectID, subjectKind, roleID string, +) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-share"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogScopeGrant, + SubjectID: fmt.Sprintf("%s/%s/%s/%s/%s/%s", + orgID, resourceType, resourceID, subjectKind, subjectID, roleID), + Policy: map[string]any{ + "org_id": orgID, + "resource_type": resourceType, + "resource_id": resourceID, + "subject_id": subjectID, + "subject_kind": subjectKind, + "role_id": roleID, + "status": "revoked", + }, + Actor: actorID, + } +} + +// revokePrincipalPolicyLogEntry describes revoking a principal: the identity +// itself stops being able to act, so nothing it was granted matters any more. +// +// The actor is passed in rather than taken from the request because +// RevokePrincipal has none — it is reached by operators and by internal +// incident paths, and it records its own audit event as the system. An entry +// with no actor is refused by Validate, so the caller states who it is acting +// as and the log carries that rather than an empty attribution. +// +// The organization is in Policy and not in the subject id: a human principal is +// org-less by schema (principals_org_scope), so the organization is a fact about +// this principal rather than part of its name. +func revokePrincipalPolicyLogEntry(actorID, principalID, orgID, reason string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-principal"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogPrincipal, + SubjectID: principalID, + Policy: map[string]any{ + "org_id": orgID, + "principal_id": principalID, + "status": "revoked", + "reason": reason, + }, + Actor: actorID, + } +} + +// revokeSourceDelegationPolicyLogEntry describes revoking one source +// delegation: the module binding stops holding the owner's authority over that +// source, and every later mint from it is refused. +func revokeSourceDelegationPolicyLogEntry(actorID, orgID, delegationID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-source-delegation"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogBinding, + SubjectID: delegationID, + Policy: map[string]any{ + "org_id": orgID, + "delegation_id": delegationID, + "status": "revoked", + "reason": SourceDelegationRevokedByAdmin, + }, + Actor: actorID, + } +} + +// removeOrgMemberPolicyLogEntry describes removing an organization membership: +// the member loses the standing every grant inside that organization hangs +// from, and the team memberships that outlive it go in the same transaction. +// +// The subject is the membership — the (organization, user) pair the removal +// addresses — rather than the user, because the user may still be a member of +// other organizations and a replay asking "what is this subject's authority" +// must not read the entry as "this person holds nothing anywhere". +func removeOrgMemberPolicyLogEntry(actorID, orgID, userID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("remove-org-member"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogBinding, + SubjectID: orgID + "/" + userID, + Policy: map[string]any{ + "org_id": orgID, + "user_id": userID, + "status": "removed", + "team_memberships": "removed", + "source_delegations": "revoked", + }, + Actor: actorID, + } +} + +// revokeAPIKeyPolicyLogEntry describes revoking an API key: the credential +// stops authenticating, so the identity it presented stops being able to act +// through it. +// +// The subject kind is the principal rather than a grant, because a key is not a +// role granted to somebody — it is an actor. Revoking it takes nothing away +// from the user who created it; it removes one of the ways that authority could +// be presented. +func revokeAPIKeyPolicyLogEntry(actorID, orgID, keyID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-api-key"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogPrincipal, + SubjectID: keyID, + Policy: map[string]any{ + "org_id": orgID, + "api_key_id": keyID, + "status": "revoked", + }, + Actor: actorID, + } +} + +// revokePlatformRolePolicyLogEntry describes removing a user's platform role: +// the widest authority this host grants, held across every organization rather +// than inside one. +// +// The subject is the user and the subject id carries no role, because the +// revocation does not name one either — platform_admins holds at most one row +// per user and the delete names the user alone. Policy says the authority that +// remains, which is none. +func revokePlatformRolePolicyLogEntry(actorID, userID string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: newPolicyLogOperationID("revoke-platform-role"), + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogBinding, + SubjectID: userID, + Policy: map[string]any{ + "user_id": userID, + "scope": "platform", + "platform_role": "none", + "status": "revoked", + }, + Actor: actorID, + } +} diff --git a/module/services/accounts/code/pkg/business/policy_log_narrowings_coverage_test.go b/module/services/accounts/code/pkg/business/policy_log_narrowings_coverage_test.go new file mode 100644 index 000000000..ad74b5b2a --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_narrowings_coverage_test.go @@ -0,0 +1,461 @@ +package business + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The narrowing gate. It reads this package's own source, derives every +// *Service method that performs a narrowing of authority, and requires each one +// to go through WithPolicyLoggedNarrowing — or to be classified, by name, with a +// reason a reader can argue with and a PREMISE this test asserts. +// +// WHY IT DERIVES THE SET RATHER THAN PINNING IT. A gate that counted wrapped +// call sites against a number would be worthless here, and not subtly: two +// branches that each add one narrowing and each bump the same number merge +// cleanly into a total that is silently wrong, and nothing in the merge looks +// like a conflict. The same is true of a pinned list of method names — the list +// is the thing a person forgets to touch. So the inventory comes out of the AST +// on every run: a method added tomorrow is in it the moment it is written, and +// a method that is neither wrapped nor classified fails this test by name. +// +// THE THREE CLASSIFICATIONS, and why there are three rather than two: +// +// - WITNESSED: the method's body calls WithPolicyLoggedNarrowing. Nothing to +// declare. +// - narrowsNoAuthority: the method's verb is a narrowing verb but what it +// narrows is not authority — a dashboard, a notification, a sign-in path, +// the caller's own second factor. Each entry carries the reason and a +// premise this test checks. +// - unwitnessedNarrowings: the method DOES narrow authority and is NOT +// witnessed. A declared debt, not a waiver. Pretending these are exempt +// would be the lie this gate exists to prevent, and leaving them +// unclassified would make the gate red for everyone and get it deleted. So +// each one is named, with what it narrows and why it is not witnessed yet, +// and the premise asserted is that it is still unwitnessed — the day +// somebody wires it, this test fails until the entry is deleted. +// +// WHAT THE PREMISES CAN AND CANNOT DO. A premise is a TRIPWIRE, not a proof: it +// fails when a classified method starts doing something its reason says it does +// not do. It cannot establish that the reason was right in the first place — +// that is a judgement, which is why every entry is a sentence rather than a +// name. Two further limits, named rather than implied away: the inventory is +// keyed on exported *Service methods whose name begins with a narrowing verb, so +// a narrowing added as an unexported helper of a method named something else is +// outside its reach, and so is one whose method name begins with a verb nobody +// thought of. Both are failures of the naming convention, and the convention is +// what the gate reads. + +// narrowingVerbs are the method-name prefixes that make a method a candidate. +// A verb added here can only ever widen the gate's reach. +var narrowingVerbs = []string{"Revoke", "Remove", "Delete", "Uninstall", "Close", "Disable"} + +// premise is what this test checks about a classified method, so the +// classification cannot quietly stop being true. +type premise int + +const ( + // premiseNoAuthorityWrite: the method's body makes no call that both names + // an authority relation and writes to it. It fails the day the method starts + // revoking a grant, a membership, a principal or a credential. + premiseNoAuthorityWrite premise = iota + // premiseOwnUserOnly: the method writes only inside a user-scoped + // transaction, so the only authority it can reach is the acting user's own. + // It fails the day the write moves to a tenant or control-plane transaction, + // where it could reach somebody else's. + premiseOwnUserOnly + // premiseStillUnwitnessed: the method does not call + // WithPolicyLoggedNarrowing. The premise of a DEBT entry, so the entry + // cannot outlive the debt. + premiseStillUnwitnessed +) + +// classification is one declared answer about one method. +type classification struct { + why string + premise premise + // absentSubjectKind, when set, is a PolicyLogSubjectKind value whose + // ABSENCE from the closed set is part of this entry's reason. Adding that + // kind fails the entry, which is the point: the reason was "the log cannot + // name this subject", and once it can, the entry has to be re-argued. + absentSubjectKind PolicyLogSubjectKind +} + +// narrowsNoAuthority are the methods whose narrowing verb is about something +// other than authority. Each reason is a claim about what the method does, and +// the premise beside it is what this test checks so the claim cannot rot. +var narrowsNoAuthority = map[string]classification{ + "DeleteDashboard": { + why: "a dashboard is a saved view. Deleting one removes content; nobody's grant, membership or credential changes, and no reader loses access to anything but the view itself.", + premise: premiseNoAuthorityWrite, + }, + "DeleteNotification": { + why: "a notification is a message to one person, deleted by that person. It confers nothing, so its removal reduces nobody's authority.", + premise: premiseOwnUserOnly, + }, + "RevokeMFADevice": { + why: "the caller removes their OWN second factor. It narrows how that person may authenticate, not what anyone may do once authenticated, and the person doing it is the person it affects.", + premise: premiseOwnUserOnly, + }, + "DeleteSubscription": { + why: "a webhook subscription is a delivery destination. Deleting one stops events being sent to an endpoint; it grants and revokes nothing, and the endpoint never held authority here.", + premise: premiseNoAuthorityWrite, + }, + "DeleteSourceCredential": { + why: "the credential is this host's own means of reaching a third-party source. Deleting it narrows what THIS host can do outward, not what any principal may do here.", + premise: premiseNoAuthorityWrite, + }, + "DeleteDatasourceAccountLink": { + why: "an account link records that a person's third-party account is theirs. It is evidence, not authority: the membership read in this method is the authorisation for the removal, never its subject.", + premise: premiseNoAuthorityWrite, + }, + "DeleteDatasourceDomain": { + why: "a claimed domain is a verified fact about an organisation. Removing the claim grants nothing and revokes nothing a principal holds.", + premise: premiseNoAuthorityWrite, + }, + "DisableOrgIdentityProvider": { + why: "disabling a provider closes a SIGN-IN path and nothing else — the method's own documentation says sessions already minted stay valid until they expire. Nobody loses a role, a membership or a scope.", + premise: premiseNoAuthorityWrite, + }, + "DisableSSO": { + why: "the same as disabling an identity provider: the organisation stops authenticating that way, while every grant inside it stands untouched.", + premise: premiseNoAuthorityWrite, + }, + "RevokeInvitation": { + why: "an invitation is an OFFER of authority that has not been taken. Until it is accepted the invitee holds nothing, so revoking it reduces nothing — and accepting one is the grant, which is a widening the log records on its own path.", + premise: premiseNoAuthorityWrite, + }, +} + +// unwitnessedNarrowings are narrowings of authority that do NOT go through the +// policy log. Each is a known hole, named with what it narrows and why it is +// still open — not a waiver of the requirement, and not a place to park a new +// one: a method added here needs a sentence somebody has to write and a reader +// can refuse. +var unwitnessedNarrowings = map[string]classification{ + "RevokeApprovedExecutableArtifact": { + why: "withdrawing consent to run an approved executable artifact narrows authority by its own name: the artifact was runnable under that installation and is not any more, so a restore that lost the revocation hands the authority back silently. It is unwitnessed for a GRANT reason and not a design one, which is why this entry can say exactly what closes it. The receipt and the narrowing have to be one transaction and that transaction is the policy log's CONTROL-PLANE one, but `19_executable_artifact_approvals.up.sql:23-24` grants SELECT/INSERT and UPDATE (revoked_at, revoked_by) to app_tenant ALONE — app_control_plane holds nothing on the table, and the tenant RLS policy is written for app_tenant. So wrapping the existing body in WithPolicyLoggedNarrowing today fails with permission denied on the revoke, which is fail-closed but broken. Closing it is a migration granting app_control_plane the same narrow UPDATE plus SELECT, a policy that admits it, and splitting the method into a read-and-authorize phase in the caller's organisation transaction followed by an append-and-revoke phase on the control plane — the shape UninstallSolution already uses, whose own comment explains why every statement then names the organisation explicitly. The split opens a check-to-revoke window, and its direction is safe: it can only revoke an approval the actor was authorized for a moment earlier, which is a narrowing.", + premise: premiseStillUnwitnessed, + }, + "DeleteRole": { + why: "deleting a custom role narrows every subject that holds it, all at once. Witnessing it needs the entry to name a subject the closed set has no kind for — the ROLE, not an assignment of it — so it waits on that kind the same way the organisation archive does.", + premise: premiseStillUnwitnessed, + }, + "DeleteTeam": { + why: "a team is a subject roles are granted to, so deleting one narrows every member through it — the same fact RemoveTeamMember witnesses one member at a time. It needs one entry for the team, which is a subject the closed set does not name either.", + premise: premiseStillUnwitnessed, + }, + "DisableAgentPrincipal": { + why: "a reversible suspension of an agent is a narrowing while it stands, and lifting it is the authorised regrant the log exists to distinguish from 'never taken away'. Both halves belong in the log, and the pair is a decision about regrants rather than a second copy of the revoke path.", + premise: premiseStillUnwitnessed, + }, + "DeleteUser": { + why: "soft-deleting an identity makes it unusable everywhere at once, which is the broadest narrowing this host performs. It is not witnessed because the subject is a user rather than a principal, and the two are deliberately separate lifecycles here.", + premise: premiseStillUnwitnessed, + }, + "DeleteDatasourceSource": { + why: "removing a source revokes the delegations that named it, in the same transaction. The delegation revocations are the narrowing, and witnessing them needs one entry for the source — the fact they all share — rather than the per-delegation entries this path would otherwise append.", + premise: premiseStillUnwitnessed, + }, + "RevokeSession": { + why: "ending a session ends live minted authority, so it is a narrowing. The closed subject-kind set names no session, and a session is the one subject whose authority expires on its own — which is the argument for a kind of its own rather than for borrowing another.", + premise: premiseStillUnwitnessed, + absentSubjectKind: "session", + }, +} + +// narrowingMethod is one candidate the AST walk found. +type narrowingMethod struct { + name string + file string + line int + witnessed bool + // authorityWrites are calls in the body that both name an authority + // relation and write to it, as the premise check reads them. + authorityWrites []string + // transactions are the transaction openers the body uses. + transactions []string +} + +func TestPolicyLogNarrowings_EveryNarrowingPathIsWitnessed(t *testing.T) { + methods := collectNarrowingMethods(t) + require.NotEmpty(t, methods, "the gate found no narrowing methods at all; the AST walk is broken") + require.True(t, len(methods) > len(narrowsNoAuthority)+len(unwitnessedNarrowings), + "every candidate the walk found is classified away and none is witnessed; that cannot be right, so the walk or the classifications are wrong") + + usedExempt := map[string]bool{} + usedDebt := map[string]bool{} + var violations []string + + for _, method := range methods { + exempt, isExempt := narrowsNoAuthority[method.name] + debt, isDebt := unwitnessedNarrowings[method.name] + + require.False(t, isExempt && isDebt, + "%s is classified both as narrowing no authority and as an unwitnessed narrowing; it cannot be both", method.name) + + switch { + case method.witnessed: + if isExempt { + violations = append(violations, fmt.Sprintf( + "%s:%d %s goes through WithPolicyLoggedNarrowing but is listed in narrowsNoAuthority; delete the entry", + method.file, method.line, method.name)) + } + if isDebt { + violations = append(violations, fmt.Sprintf( + "%s:%d %s is witnessed now — delete its unwitnessedNarrowings entry, the debt is paid", + method.file, method.line, method.name)) + } + case isExempt: + usedExempt[method.name] = true + _ = exempt + case isDebt: + usedDebt[method.name] = true + _ = debt + default: + violations = append(violations, fmt.Sprintf( + "%s:%d %s narrows authority by its name and does not go through WithPolicyLoggedNarrowing. "+ + "Wrap it with an entry builder in policy_log_narrowings.go — or, if it narrows something other "+ + "than authority, add it to narrowsNoAuthority with the sentence that says why; if it does narrow "+ + "authority and cannot be witnessed yet, declare it in unwitnessedNarrowings with what it narrows "+ + "and what is blocking.", + method.file, method.line, method.name)) + } + } + + sort.Strings(violations) + require.Empty(t, violations, "narrowing coverage violations:\n%s", strings.Join(violations, "\n")) + + for name := range narrowsNoAuthority { + require.True(t, usedExempt[name], + "stale narrowsNoAuthority entry %q: no such unwitnessed narrowing-verb method any more — delete the entry", name) + } + for name := range unwitnessedNarrowings { + require.True(t, usedDebt[name], + "stale unwitnessedNarrowings entry %q: no such unwitnessed narrowing-verb method any more — delete the entry", name) + } +} + +// TestPolicyLogNarrowings_ClassificationPremisesHold is the half that makes the +// classifications worth having. Each entry says what its method does; this +// asserts the thing that would have to change for the sentence to be false. +func TestPolicyLogNarrowings_ClassificationPremisesHold(t *testing.T) { + byName := map[string]narrowingMethod{} + for _, method := range collectNarrowingMethods(t) { + byName[method.name] = method + } + + kinds := map[PolicyLogSubjectKind]bool{} + for _, kind := range []PolicyLogSubjectKind{ + PolicyLogPrincipal, PolicyLogBinding, PolicyLogInstallation, + PolicyLogTeamMembership, PolicyLogScopeGrant, + } { + kinds[kind] = true + } + + check := func(t *testing.T, name string, declared classification) { + t.Helper() + method, found := byName[name] + require.True(t, found, "%s is classified but no longer exists; the stale-entry check in the test above covers this", name) + + switch declared.premise { + case premiseNoAuthorityWrite: + require.Empty(t, method.authorityWrites, + "%s is classified as narrowing no authority (%s) but its body writes to an authority relation: %s. "+ + "Either the classification is wrong and this is a narrowing to witness, or the call is a read "+ + "the premise check cannot tell from a write.", + name, declared.why, strings.Join(method.authorityWrites, ", ")) + case premiseOwnUserOnly: + require.NotEmpty(t, method.transactions, + "%s is classified as reaching only the acting user's own rows, but opens no transaction at all; the premise cannot be checked", name) + for _, opener := range method.transactions { + require.Equal(t, "WithUserTx", opener, + "%s is classified as reaching only the acting user's own rows (%s) but writes inside %s, which can reach somebody else's", + name, declared.why, opener) + } + case premiseStillUnwitnessed: + require.False(t, method.witnessed, + "%s is declared an unwitnessed narrowing but is witnessed now; delete its entry", name) + } + + if declared.absentSubjectKind != "" { + require.False(t, kinds[declared.absentSubjectKind], + "%s is classified on the grounds that the closed subject-kind set cannot name a %q, and now it can. "+ + "Re-argue the entry: the reason it rested on is gone.", + name, declared.absentSubjectKind) + require.False(t, declared.absentSubjectKind.Valid(), + "%q is accepted by PolicyLogSubjectKind.Valid but is not in the set this test enumerates; one of the two is out of date", + declared.absentSubjectKind) + } + } + + for name, declared := range narrowsNoAuthority { + t.Run("narrows_no_authority/"+name, func(t *testing.T) { check(t, name, declared) }) + } + for name, declared := range unwitnessedNarrowings { + t.Run("unwitnessed/"+name, func(t *testing.T) { check(t, name, declared) }) + } +} + +// authorityRelations are the words that make a call's target an authority +// relation. A method that WRITES to one of these is narrowing or widening what +// somebody may do, whatever its own name says. +var authorityRelations = []string{ + "Role", "Permission", "Grant", "Share", "Scope", "Member", "Membership", + "Principal", "APIKey", "Delegation", "Installation", "Team", "Session", + "PlatformAdmin", +} + +// readPrefixes are the call-name prefixes that make a call a read. Everything +// else that names an authority relation is treated as a write, which is the +// conservative direction: a misread read shows up as a classification to +// justify, a misread write shows up as nothing at all. +var readPrefixes = []string{ + "Get", "List", "Count", "Exists", "Resolve", "Lookup", "Load", "Read", + "Find", "Is", "Has", "Can", "Require", "Verify", "Check", "Declared", +} + +// transactionOpeners are the calls that open a store transaction, so a premise +// about which rows a method can reach has something to read. +var transactionOpeners = []string{"WithOrgTx", "WithUserTx", "WithControlPlane", "Within"} + +// collectNarrowingMethods parses the package's own non-test sources and returns +// every exported *Service method whose name begins with a narrowing verb, with +// what the gate needs to know about its body. +func collectNarrowingMethods(t *testing.T) []narrowingMethod { + t.Helper() + + fset := token.NewFileSet() + entries, err := os.ReadDir(".") + require.NoError(t, err) + + var out []narrowingMethod + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + parsed, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0) + require.NoError(t, err, "parse %s", name) + + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Body == nil { + continue + } + if !isServiceReceiver(fn.Recv) || !ast.IsExported(fn.Name.Name) || !hasNarrowingVerb(fn.Name.Name) { + continue + } + method := narrowingMethod{ + name: fn.Name.Name, + file: name, + line: fset.Position(fn.Pos()).Line, + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + called := calledName(call) + if called == "" { + return true + } + switch { + case called == "WithPolicyLoggedNarrowing": + method.witnessed = true + case contains(transactionOpeners, called): + method.transactions = append(method.transactions, called) + case namesAuthorityRelation(called) && !hasReadPrefix(called) && !isValueProjection(called): + method.authorityWrites = append(method.authorityWrites, called) + } + return true + }) + out = append(out, method) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out +} + +// isServiceReceiver reports whether a method is on *Service, whatever the +// receiver is named. Keying on a receiver named "s" would let a method that +// names its receiver anything else narrow authority unseen. +func isServiceReceiver(recv *ast.FieldList) bool { + if recv == nil || len(recv.List) != 1 { + return false + } + star, ok := recv.List[0].Type.(*ast.StarExpr) + if !ok { + return false + } + ident, ok := star.X.(*ast.Ident) + return ok && ident.Name == "Service" +} + +func hasNarrowingVerb(name string) bool { + for _, verb := range narrowingVerbs { + if strings.HasPrefix(name, verb) { + return true + } + } + return false +} + +// calledName is the name a call expression invokes — the selector for a method +// call, the identifier for a plain function call. +func calledName(call *ast.CallExpr) string { + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + return fun.Sel.Name + case *ast.Ident: + return fun.Name + } + return "" +} + +func namesAuthorityRelation(called string) bool { + for _, relation := range authorityRelations { + if strings.Contains(called, relation) { + return true + } + } + return false +} + +// isValueProjection reports whether a call is a pure conversion of a value +// already in hand — an enum to the spelling a column uses, a row to its wire +// form. It names an authority relation and touches none: there is no relation +// on the other side of it to write to. +func isValueProjection(called string) bool { + return strings.Contains(called, "ToString") || + strings.Contains(called, "ToProto") || + strings.Contains(called, "Payload") || + strings.Contains(called, "Labels") +} + +func hasReadPrefix(called string) bool { + for _, prefix := range readPrefixes { + if strings.HasPrefix(called, prefix) || strings.HasPrefix(called, strings.ToLower(prefix[:1])+prefix[1:]) { + return true + } + } + return false +} + +func contains(haystack []string, needle string) bool { + for _, candidate := range haystack { + if candidate == needle { + return true + } + } + return false +} diff --git a/module/services/accounts/code/pkg/business/policy_log_narrowings_wiring_test.go b/module/services/accounts/code/pkg/business/policy_log_narrowings_wiring_test.go new file mode 100644 index 000000000..df87552a7 --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_narrowings_wiring_test.go @@ -0,0 +1,251 @@ +//go:build pure + +package business + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// What each narrowing tells the log, and that the narrowing paths genuinely run +// under the protocol rather than merely having an entry builder nearby. +// +// The coverage gate beside this file reads the source and answers "is every +// narrowing wrapped". That is a structural question, and it cannot answer the +// two that matter next: whether the entry a wrapped path offers the log is one +// the log would accept, and whether a host with no log actually REFUSES the +// operation. Both are behavioural, so both are asserted here by calling the +// real methods. + +// everyNarrowingEntry is each builder with the inputs one call site passes it. +// Table-driven so a builder added without a case here shows up in the count +// assertion below rather than silently going unchecked. +func everyNarrowingEntry() []struct { + name string + entry *PolicyLogEntry + wantKind PolicyLogSubjectKind + wantSubject string + wantActor string +} { + return []struct { + name string + entry *PolicyLogEntry + wantKind PolicyLogSubjectKind + wantSubject string + wantActor string + }{ + { + name: "uninstall-solution", + entry: uninstallPolicyLogEntry("admin-1", "org-1", "install-1"), + wantKind: PolicyLogInstallation, + wantSubject: "install-1", + wantActor: "admin-1", + }, + { + name: "revoke-scope", + entry: revokeScopePolicyLogEntry("admin-1", "org-1", "user-1", "SUBJECT_KIND_USER", "/acme/eu", "role-1"), + wantKind: PolicyLogScopeGrant, + wantSubject: "org-1/SUBJECT_KIND_USER/user-1//acme/eu/role-1", + wantActor: "admin-1", + }, + { + name: "remove-team-member", + entry: removeTeamMemberPolicyLogEntry("admin-1", "org-1", "team-1", "user-1"), + wantKind: PolicyLogTeamMembership, + wantSubject: "team-1/user-1", + wantActor: "admin-1", + }, + { + name: "delete-organization", + entry: deletedOrganizationPolicyLogEntry("admin-1", "org-1"), + wantKind: PolicyLogOrganization, + wantSubject: "org-1", + wantActor: "admin-1", + }, + { + name: "revoke-role", + entry: revokeRolePolicyLogEntry("admin-1", "org-1", "user-1", "role-1", "/acme"), + wantKind: PolicyLogScopeGrant, + wantSubject: "org-1/user-1/role-1//acme", + wantActor: "admin-1", + }, + { + name: "revoke-share", + entry: revokeSharePolicyLogEntry("admin-1", "org-1", "document", "doc-1", "user-1", "SUBJECT_KIND_USER", "role-1"), + wantKind: PolicyLogScopeGrant, + wantSubject: "org-1/document/doc-1/SUBJECT_KIND_USER/user-1/role-1", + wantActor: "admin-1", + }, + { + name: "revoke-principal", + entry: revokePrincipalPolicyLogEntry("system", "principal-1", "org-1", "credential compromise"), + wantKind: PolicyLogPrincipal, + wantSubject: "principal-1", + wantActor: "system", + }, + { + name: "revoke-source-delegation", + entry: revokeSourceDelegationPolicyLogEntry("admin-1", "org-1", "delegation-1"), + wantKind: PolicyLogBinding, + wantSubject: "delegation-1", + wantActor: "admin-1", + }, + { + name: "remove-org-member", + entry: removeOrgMemberPolicyLogEntry("admin-1", "org-1", "user-1"), + wantKind: PolicyLogBinding, + wantSubject: "org-1/user-1", + wantActor: "admin-1", + }, + { + name: "revoke-api-key", + entry: revokeAPIKeyPolicyLogEntry("admin-1", "org-1", "key-1"), + wantKind: PolicyLogPrincipal, + wantSubject: "key-1", + wantActor: "admin-1", + }, + { + name: "revoke-platform-role", + entry: revokePlatformRolePolicyLogEntry("admin-1", "user-1"), + wantKind: PolicyLogBinding, + wantSubject: "user-1", + wantActor: "admin-1", + }, + { + name: "close-solution-target", + entry: closedSolutionTargetPolicyLogEntry("op-1", "target-1", "binding-1", "solution-1", 4), + wantKind: PolicyLogInstallation, + wantSubject: "target-1", + wantActor: "solution:solution-1", + }, + } +} + +// Every entry a narrowing offers the log has to be one the log would accept and +// a replay could read: a valid subject kind, a subject it names, an actor, and +// the resulting authority rather than a diff. +func TestNarrowingEntries_AreReplayable(t *testing.T) { + for _, tc := range everyNarrowingEntry() { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, tc.entry.Validate(), + "the log refuses this entry, so the narrowing it describes would be refused too") + require.Equal(t, PolicyLogNarrowed, tc.entry.Decision) + require.Equal(t, tc.wantKind, tc.entry.SubjectKind) + require.True(t, tc.entry.SubjectKind.Valid(), + "a subject kind outside the closed set is one no reconciliation could act on") + require.Equal(t, tc.wantSubject, tc.entry.SubjectID) + require.Equal(t, tc.wantActor, tc.entry.Actor) + require.NotEmpty(t, tc.entry.Policy, + "an entry with no policy says nothing about the authority that remains") + require.Contains(t, tc.entry.Policy, "status", + "Policy carries the authority as it stands AFTER the operation, so each one states the resulting status") + }) + } +} + +// The operation id is FRESH on every attempt, deliberately: an id derived from +// the operation's inputs collides with a REVOKE → REGRANT → REVOKE sequence and +// lets the second revocation apply against the first's receipt, unwitnessed. +// Over-recording is the safe direction (policy_log_narrowings.go says why), and +// this holds the builders to it. +// +// The one exception is the solution-target close, whose id is passed in because +// the apply transaction has to recognise the close it was appended for. +func TestNarrowingEntries_OperationIDsAreFreshPerAttempt(t *testing.T) { + first := everyNarrowingEntry() + second := everyNarrowingEntry() + for i, tc := range first { + if tc.name == "close-solution-target" { + require.Equal(t, tc.entry.OperationID, second[i].entry.OperationID, + "the close's id is the caller's, so two builds of it must agree") + continue + } + t.Run(tc.name, func(t *testing.T) { + require.NotEmpty(t, tc.entry.OperationID) + require.NotEqual(t, tc.entry.OperationID, second[i].entry.OperationID, + "two attempts at the same narrowing derived the same operation id; a retry would reuse the first receipt and the second narrowing would apply unwitnessed") + }) + } +} + +// A host with NO policy log refuses to narrow, at the real entry points. This is +// the wiring assertion: the refusal can only come from +// WithPolicyLoggedNarrowing, so a call site that stopped going through it — or +// never did — fails here with its own name. +// +// The five below are the narrowings reachable with no database: the rest read +// the store before they append (an actor type, a principal's organisation, the +// caller's platform role), and the production path covers those against real +// PostgreSQL in policy_log_production_path_test.go. +func TestWitnessedNarrowings_AHostWithNoLogRefuses(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + name string + call func(*Service) error + }{ + {"RevokeRole", func(s *Service) error { + return s.RevokeRole(ctx, "admin-1", &gen.RevokeRoleRequest{ + OrgId: "org-1", SubjectId: "user-1", RoleId: "role-1", Scope: "/acme", + }) + }}, + {"RevokeShare", func(s *Service) error { + return s.RevokeShare(ctx, "admin-1", &gen.RevokeShareRequest{ + OrgId: "org-1", ResourceType: "document", ResourceId: "doc-1", + SubjectId: "user-1", RoleId: "role-1", + }) + }}, + {"RevokeAPIKey", func(s *Service) error { + return s.RevokeAPIKey(ctx, "admin-1", &gen.RevokeAPIKeyRequest{ + Id: "key-1", OrganizationId: "org-1", + }) + }}, + {"RemoveOrgMember", func(s *Service) error { + return s.RemoveOrgMember(ctx, "admin-1", &gen.RemoveOrgMemberRequest{ + OrgId: "org-1", UserId: "user-1", + }) + }}, + {"RevokeSourceDelegation", func(s *Service) error { + _, err := s.RevokeSourceDelegation(ctx, "admin-1", "org-1", "delegation-1") + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + // No SetPolicyLog: this host cannot witness anything. The store + // panics on any method these paths were not meant to reach, so a + // call that got as far as writing would say so loudly rather than + // passing. + service := &Service{store: noopControlPlaneStore{}} + require.ErrorIs(t, tc.call(service), ErrPolicyLogUnreachable, + "%s narrowed (or tried to) on a host with no policy log; it is not going through WithPolicyLoggedNarrowing", tc.name) + }) + } +} + +// The organisation archive names the ORGANISATION, and this is what that +// stopgap became. +// +// It was written the other way round first: deletedOrganizationPolicyLogEntry +// reported PolicyLogBinding because the closed set had no kind for an +// organisation, and this test was the tripwire on that label — it required the +// stopgap to still be the nearest available statement, and failed the day a +// real kind appeared. The kind now exists, so the assertion inverts: the entry +// must use it, and must NOT be a binding. +// +// PolicyLogBinding is asserted against explicitly rather than just asserting +// the new value, because reverting to it is the specific regression this +// covers: an archive reported as a binding sends a reconciliation looking for a +// binding whose id is an organisation's, which it will never find. +func TestDeletedOrganizationEntryNamesTheOrganisationItArchives(t *testing.T) { + entry := deletedOrganizationPolicyLogEntry("admin-1", "org-1") + require.Equal(t, PolicyLogOrganization, entry.SubjectKind) + require.NotEqual(t, PolicyLogBinding, entry.SubjectKind, + "an archive is not a binding; a replay reading it would look for a binding whose id is an organisation's") + require.True(t, entry.SubjectKind.Valid(), + "the kind must be in the closed set, or a reconciliation cannot act on the entry at all") + require.Equal(t, "org-1", entry.SubjectID) + require.NoError(t, entry.Validate()) +} diff --git a/module/services/accounts/code/pkg/business/policy_log_production_path_test.go b/module/services/accounts/code/pkg/business/policy_log_production_path_test.go new file mode 100644 index 000000000..ebea8077a --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_production_path_test.go @@ -0,0 +1,559 @@ +//go:build !pure + +package business_test + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The policy log on the PRODUCTION PATH: real narrowings, through the real +// service, against real PostgreSQL. +// +// WHAT IS REAL HERE AND WHAT IS NOT, because the distinction is the whole value +// of this file. The thing under test is the HOST'S half of the protocol — that a +// real uninstall appends before it applies, that the receipt and the revocation +// are one transaction, and that a commit which fails after an append landed +// stops this host serving. Every part of that half is the production one: +// `Service.UninstallSolution`, `WithPolicyLoggedNarrowing`, the receipts in +// `policy_log_commits` through `infra.PostgresStore`, and `RequireServing`. +// +// The external log is the one collaborator that is a double, and it has to be: +// it is external by design, it lives in a warehouse, and no deployment of it +// exists (pkg/infra/warehouse_policy_log.go says why). A double for a +// collaborator is not a double for the thing under test — the mistake this PR +// has already paid for twice is faking the PolicyLogStore, which IS the thing +// under test, and that one is real postgres here. +// +// The commit failures below are not injected either. They are real database +// errors from real production statements given input they cannot accept, which +// is why the gap they leave is a gap the real store wrote. + +// recordingPolicyLog stands in for the external warehouse log: it issues real +// monotonic sequences and remembers every entry, so a test can assert what the +// host offered it and in what order. +type recordingPolicyLog struct { + mu sync.Mutex + entries []*business.PolicyLogEntry + records []*business.PolicyLogRecord + sequence uint64 + failWith error +} + +func (l *recordingPolicyLog) Append( + _ context.Context, entry *business.PolicyLogEntry, +) (*business.PolicyLogReceipt, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.failWith != nil { + return nil, l.failWith + } + l.sequence++ + receipt := &business.PolicyLogReceipt{ + Receipt: "warehouse-receipt-" + entry.OperationID, + Sequence: l.sequence, + AppendedAt: time.Now().UTC(), + } + copied := *entry + l.entries = append(l.entries, &copied) + l.records = append(l.records, &business.PolicyLogRecord{ + PolicyLogEntry: copied, + Receipt: receipt.Receipt, + Sequence: receipt.Sequence, + LoggedAt: receipt.AppendedAt, + }) + return receipt, nil +} + +func (l *recordingPolicyLog) Entries( + _ context.Context, after uint64, _ int, +) ([]*business.PolicyLogRecord, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.failWith != nil { + return nil, l.failWith + } + var out []*business.PolicyLogRecord + for _, record := range l.records { + if record.Sequence > after { + out = append(out, record) + } + } + return out, nil +} + +// appended returns the entries offered since a mark, so a test reads only its +// own narrowings out of a log the whole package shares. +func (l *recordingPolicyLog) appended(from int) []*business.PolicyLogEntry { + l.mu.Lock() + defer l.mu.Unlock() + return append([]*business.PolicyLogEntry(nil), l.entries[from:]...) +} + +func (l *recordingPolicyLog) mark() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.entries) +} + +// testPolicyLog is the log every business DB test's narrowings go through. +// +// Wired for the WHOLE package rather than per test, on purpose: once a narrowing +// requires a witness, a service without a log cannot uninstall, revoke a scope +// or remove a team member at all, so every test that performs one would be +// testing a host in a configuration production is not meant to have. +var testPolicyLog = &recordingPolicyLog{} + +// wirePolicyLogForTests gives the package's service the protocol's two halves: +// the double for the external log, and the REAL postgres store for the local +// receipts and cursor. +func wirePolicyLogForTests(service *business.Service, store business.PolicyLogStore) { + service.SetPolicyLog(testPolicyLog, store) +} + +// reachTheLog runs a real reconciliation pass so `reached_at` is fresh. +// +// The serving gate refuses a host that has not reached the log inside the +// staleness window, and a host that has never reached it at all has a NULL +// `reached_at` — so without this a serving assertion would pass for the wrong +// reason, reporting the gap it was looking for as staleness. +func reachTheLog(t *testing.T, ctx context.Context) *business.PolicyLogServingState { + t.Helper() + state, err := testService.ReconcilePolicyLog(ctx) + require.NoError(t, err) + return state +} + +// closeGap commits an operation whose narrowing never applied, so one test's +// deliberate gap does not keep the host shut for every test after it. +// ClearAll does not touch the policy log relations — a receipt is evidence an +// append happened and the schema makes neither deletable — so the cleanup is a +// commit, which is also what an operator closing a real gap does. +func closeGap(t *testing.T, ctx context.Context, operationID string) { + t.Helper() + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + return testStore.CommitPolicyLogOperation(ctx, operationID, time.Now().UTC()) + })) +} + +// closeEveryGap is the cleanup a test that deliberately opens a gap owes the +// tests after it. +// +// It runs even when the test FAILS before reaching its own close, which is the +// case that matters: a gap left behind keeps every later serving assertion +// refusing, and that refusal reads as the later test's own bug. Registered with +// t.Cleanup rather than written at the end of the body for exactly that reason. +func closeEveryGap(t *testing.T, ctx context.Context) { + t.Helper() + for _, gap := range uncommittedOperations(t, ctx) { + closeGap(t, ctx, gap.OperationID) + } +} + +// requireCommitted asserts that one operation is NOT among the uncommitted, +// rather than that nothing is. +// +// `clearData` cannot reset the policy log relations — neither is deletable, by +// design, because a receipt is the evidence that an append happened — so a gap +// another test left behind is still in the table. Asserting emptiness would make +// this test report somebody else's leftover as its own failure; asserting about +// the operation it appended is the claim it can actually make. +func requireCommitted(t *testing.T, ctx context.Context, operationID string) { + t.Helper() + for _, gap := range uncommittedOperations(t, ctx) { + require.NotEqual(t, operationID, gap.OperationID, + "the narrowing committed, so its receipt must have committed in the same transaction") + } +} + +func uncommittedOperations(t *testing.T, ctx context.Context) []*business.PolicyLogGap { + t.Helper() + var gaps []*business.PolicyLogGap + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + out, err := testStore.UncommittedPolicyLogOperations(ctx) + gaps = out + return err + })) + return gaps +} + +// A real uninstall, through the real service: it appends, and the revocation and +// the receipt's commit land together. +// +// This is the test the condition asks for. Nothing is stubbed on the host's +// side: the installation is composed by InstallSolution, revoked by +// UninstallSolution, and the receipt is read back out of PostgreSQL. +func TestARealUninstallAppendsAndThenCommitsItsReceipt(t *testing.T) { + clearData(t) + ctx := testCtx + mark := testPolicyLog.mark() + + adminID, orgID := mustUserAndOrg(t, ctx, "pluninstall@example.com", "pluninstall", "PolicyLog Co") + roleID := seedInstallableRole(t, ctx, orgID) + targetID, _ := declarePresence(t, "pluninstall-solution") + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/pluninstall:1.0.0", + TargetID: targetID, + RootScopeLabel: "PolicyLog Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + + // An install is not a narrowing, so it must not have been offered to the + // log. A protocol that appended on every write would make "was authority + // reduced" unanswerable from the log. + require.Empty(t, testPolicyLog.appended(mark), + "installing is not a narrowing and must not reach the policy log") + + require.NoError(t, testService.UninstallSolution(ctx, adminID, orgID, installation.Id)) + + offered := testPolicyLog.appended(mark) + require.Len(t, offered, 1, "one uninstall is one appended entry") + entry := offered[0] + require.Equal(t, business.PolicyLogNarrowed, entry.Decision) + require.Equal(t, business.PolicyLogInstallation, entry.SubjectKind) + require.Equal(t, installation.Id, entry.SubjectID) + require.Equal(t, adminID, entry.Actor) + require.Equal(t, orgID, entry.Policy["org_id"]) + require.Equal(t, "revoked", entry.Policy["status"], + "the entry carries the authority as it stands AFTER the operation") + + // The receipt committed, so this operation is no gap: the revocation and the + // receipt were one transaction. + requireCommitted(t, ctx, entry.OperationID) + + // And the narrowing really happened — the receipt is not the only thing + // that landed. + _, err = testStore.LiveModuleAuthority(ctx, business.ModulePrincipalID("pluninstall"), orgID, installation.Id) + require.ErrorIs(t, err, business.ErrModuleInstallationInactive) +} + +// A commit that fails AFTER its append landed stops this host serving, and keeps +// it stopped until the gap closes. +// +// The failure is real: RevokeScope's production statement is handed an +// organisation id PostgreSQL cannot cast to uuid, so the transaction carrying +// both the delete and the receipt's commit aborts — after the append has already +// been accepted by the log and recorded here. That is precisely the +// append-ok/commit-fail state, reached without reaching into the protocol. +func TestACommitFailingAfterTheAppendStopsThisHostServing(t *testing.T) { + clearData(t) + ctx := testCtx + mark := testPolicyLog.mark() + // The gate refuses on ANY gap, so a gap another test left in a relation + // nothing truncates would make the refusal below unattributable. Closed + // before, and closed after even if this test fails on the way. + closeEveryGap(t, ctx) + t.Cleanup(func() { closeEveryGap(t, ctx) }) + + // Serving first, so the refusal below is attributable to the gap. + reachTheLog(t, ctx) + require.NoError(t, testService.RequireServing(ctx), + "the host must be serving before the gap, or the assertion proves nothing") + + err := testService.RevokeScope(ctx, "policy-log-actor", &gen.RevokeScopeRequest{ + OrgId: "not-a-uuid", + SubjectId: "11111111-1111-1111-1111-111111111111", + SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, + ScopePath: "root", + RoleId: "22222222-2222-2222-2222-222222222222", + }) + require.Error(t, err, "the narrowing's transaction must have failed") + + offered := testPolicyLog.appended(mark) + require.Len(t, offered, 1, "the append must have landed before the commit was attempted") + operationID := offered[0].OperationID + + gaps := uncommittedOperations(t, ctx) + require.Len(t, gaps, 1) + require.Equal(t, operationID, gaps[0].OperationID, + "the appended-but-unapplied operation is the one whose commit failed") + + // THE SERVING CONSEQUENCE. The log says a narrowing happened and this host + // has not applied it, so it must not serve the wider authority it still + // holds locally. + servingErr := testService.RequireServing(ctx) + require.Error(t, servingErr, "a host holding an unapplied logged narrowing must stop serving") + require.Equal(t, codes.Unavailable, status.Code(servingErr), + "an unreconciled host is UNABLE, not refusing the caller: a PermissionDenied here would tell "+ + "every caller their authority was revoked when nothing of theirs was") + require.Contains(t, servingErr.Error(), "not applied here") + + ok, state, err := testService.MayServe(ctx) + require.NoError(t, err) + require.False(t, ok) + require.Len(t, state.Gaps, 1) + require.False(t, state.Unreachable, "the log was reachable; the gap is the reason") + + // Still refused on a later request, not just the first: the gate is not a + // one-shot and the cached answer does not expire into serving. + require.Error(t, testService.RequireServing(ctx)) + + // EVERY REPLICA, not only the one whose commit failed. A second host over + // the same database — its own process state, its own gate cache, having + // performed no narrowing itself — must refuse too, because the gap lives in + // the shared control-plane relation rather than in the memory of the + // process that opened it. A gap that closed only its own replica would + // leave the others serving exactly the authority the log says was revoked. + replica, err := business.NewService(testStore) + require.NoError(t, err) + replica.SetPolicyLog(testPolicyLog, testStore) + replicaErr := replica.RequireServing(ctx) + require.Error(t, replicaErr, "a replica that did not perform the narrowing must still stop serving") + require.Equal(t, codes.Unavailable, status.Code(replicaErr)) + + // Closing the gap resumes serving. Nothing else changed, so the gap is what + // the gate was refusing on. + closeGap(t, ctx, operationID) + require.Empty(t, uncommittedOperations(t, ctx)) + require.NoError(t, testService.RequireServing(ctx), + "the host must serve again once the gap is closed") + require.NoError(t, replica.RequireServing(ctx), + "the replica must serve again too: it read the gap from the shared relation, "+ + "so closing it there is what reopens every host") +} + +// An append the log refuses narrows nothing, and leaves the host serving. +// +// The other half of the asymmetry: authority was not reduced and nobody was told +// it was, so there is nothing to fail closed about. +func TestANarrowingTheLogRefusesChangesNothing(t *testing.T) { + clearData(t) + ctx := testCtx + mark := testPolicyLog.mark() + + adminID, orgID := mustUserAndOrg(t, ctx, "plrefuse@example.com", "plrefuse", "Refuse Co") + roleID := seedInstallableRole(t, ctx, orgID) + targetID, _ := declarePresence(t, "plrefuse-solution") + installation, err := testService.InstallSolution(ctx, adminID, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/plrefuse:1.0.0", + TargetID: targetID, + RootScopeLabel: "Refuse Solution", + RoleID: roleID, + AllowedScopes: []string{"doc"}, + }) + require.NoError(t, err) + + testPolicyLog.mu.Lock() + testPolicyLog.failWith = errPolicyLogDown + testPolicyLog.mu.Unlock() + defer func() { + testPolicyLog.mu.Lock() + testPolicyLog.failWith = nil + testPolicyLog.mu.Unlock() + }() + + err = testService.UninstallSolution(ctx, adminID, orgID, installation.Id) + require.Error(t, err) + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + + require.Empty(t, testPolicyLog.appended(mark)) + require.Empty(t, uncommittedOperations(t, ctx), + "a refused append records no receipt, so there is no gap") + + // The installation is untouched: the narrowing did not happen. + _, err = testStore.LiveModuleAuthority(ctx, business.ModulePrincipalID("plrefuse"), orgID, installation.Id) + require.NoError(t, err, "a refused append must leave authority exactly as it was") +} + +var errPolicyLogDown = &policyLogDownError{} + +type policyLogDownError struct{} + +func (*policyLogDownError) Error() string { return "warehouse unreachable" } + +// A real team-membership removal and a real scope-grant revocation both append. +// +// Together with the uninstall above and the withdrawal test beside it, this is +// the set the condition names; they are one test because each is the same +// assertion about a different call site. +func TestTeamRemovalAndScopeRevocationAppendBeforeTheyApply(t *testing.T) { + clearData(t) + ctx := testCtx + + ownerID, orgID := mustUserAndOrg(t, ctx, "plteam@example.com", "plteam", "Team Co") + memberID, _ := mustUserAndOrg(t, ctx, "plmember@example.com", "plmember", "Member Co") + require.NoError(t, testService.AddOrgMember(ctx, ownerID, &gen.AddOrgMemberRequest{ + OrgId: orgID, UserId: memberID, Role: gen.OrgRole_ORG_ROLE_MEMBER, + })) + team, err := testService.CreateTeam(ctx, ownerID, &gen.CreateTeamRequest{OrgId: orgID, Name: "Operators"}) + require.NoError(t, err) + require.NoError(t, testService.AddTeamMember(ctx, ownerID, &gen.AddTeamMemberRequest{ + TeamId: team.Team.Id, UserId: memberID, Role: gen.TeamRole_TEAM_ROLE_MEMBER, + })) + + mark := testPolicyLog.mark() + require.NoError(t, testService.RemoveTeamMember(ctx, ownerID, &gen.RemoveTeamMemberRequest{ + TeamId: team.Team.Id, UserId: memberID, + })) + offered := testPolicyLog.appended(mark) + require.Len(t, offered, 1) + require.Equal(t, business.PolicyLogTeamMembership, offered[0].SubjectKind) + require.Equal(t, team.Team.Id+"/"+memberID, offered[0].SubjectID) + require.Equal(t, business.PolicyLogNarrowed, offered[0].Decision) + requireCommitted(t, ctx, offered[0].OperationID) + + roleID := seedInstallableRole(t, ctx, orgID) + // A grant must name a REGISTERED node: the scope tree is the authority, so + // granting on a path nothing registered is refused before the policy log is + // reached, and the revocation below would have nothing to narrow. + _, err = testService.RegisterScopeNode(ctx, ownerID, &gen.RegisterScopeNodeRequest{ + OrgId: orgID, ScopePath: "plsol", Kind: business.ScopeNodeKindSolution, Label: "PolicyLog Solution", + }) + require.NoError(t, err) + grant, err := testService.GrantScope(ctx, ownerID, &gen.GrantScopeRequest{ + OrgId: orgID, + SubjectId: memberID, + SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, + ScopePath: "plsol", + RoleId: roleID, + }) + require.NoError(t, err) + + mark = testPolicyLog.mark() + require.NoError(t, testService.RevokeScope(ctx, ownerID, &gen.RevokeScopeRequest{ + OrgId: orgID, + SubjectId: memberID, + SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, + ScopePath: grant.Grant.ScopePath, + RoleId: grant.Grant.RoleId, + })) + offered = testPolicyLog.appended(mark) + require.Len(t, offered, 1) + require.Equal(t, business.PolicyLogScopeGrant, offered[0].SubjectKind) + require.True(t, strings.HasPrefix(offered[0].SubjectID, orgID+"/"), + "a scope grant is named by the key the revocation is addressed with") + requireCommitted(t, ctx, offered[0].OperationID) +} + +// A read the log refuses does NOT advance the cursor, which is what makes the +// staleness window close and the host stop serving. +// +// The window itself is unit-tested against a fixed clock (policy_log_test.go); +// what only a real store can show is that a failed pass leaves `reached_at` +// exactly where the last successful one put it. A pass that advanced it on +// failure would keep a host that cannot read the log reporting itself current +// forever — the deleted UnavailablePolicyLog's permanent-outage twin, failing +// open instead of closed. +func TestAFailedReadDoesNotAdvanceTheCursorThatKeepsTheHostServing(t *testing.T) { + clearData(t) + ctx := testCtx + + closeEveryGap(t, ctx) + reachTheLog(t, ctx) + require.NoError(t, testService.RequireServing(ctx)) + + var reachedBefore *time.Time + var sequenceBefore uint64 + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + sequenceBefore, reachedBefore, err = testStore.PolicyLogCursorState(ctx) + return err + })) + require.NotNil(t, reachedBefore) + + testPolicyLog.mu.Lock() + testPolicyLog.failWith = errPolicyLogDown + testPolicyLog.mu.Unlock() + defer func() { + testPolicyLog.mu.Lock() + testPolicyLog.failWith = nil + testPolicyLog.mu.Unlock() + reachTheLog(t, ctx) + }() + + _, err := testService.ReconcilePolicyLog(ctx) + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + + var reachedAfter *time.Time + var sequenceAfter uint64 + require.NoError(t, testStore.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + sequenceAfter, reachedAfter, err = testStore.PolicyLogCursorState(ctx) + return err + })) + require.NotNil(t, reachedAfter) + require.True(t, reachedBefore.Equal(*reachedAfter), + "an unreachable log must not refresh the instant the staleness window is measured from") + require.Equal(t, sequenceBefore, sequenceAfter, + "an unreachable log must not advance the reconciled sequence past entries nobody read") +} + +// A real withdrawal, through the real reconciler: closing the solution target +// appends before it closes, and commits its receipt in the same transaction. +// +// The target close is the narrowing with the widest blast radius of the four — +// it revokes EVERY active installation naming the target — and it is the only +// one that does not start from an RPC, so it is the one most easily left off +// the protocol. It is driven here the way delivery drives it: a present +// generation, then a tombstone, through the reconciler's own pass. +func TestARealWithdrawalAppendsBeforeItClosesTheTarget(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + present := declaredBinding(t, solutionID, 4) + mount := &deliveredSet{} + mount.put(t, present) + reconciler := newTestReconciler(t, mount) + require.NoError(t, reconciler.RunOnce(testCtx)) + opened := liveTarget(t, present.Binding) + require.NotNil(t, opened) + + // Presenting is not a narrowing: opening a target starts a period an + // organisation may consent to, and nothing's authority was reduced. + mark := testPolicyLog.mark() + require.Empty(t, testPolicyLog.appended(mark)) + + mount.put(t, tombstoneOf(t, present, 5)) + require.NoError(t, reconciler.RunOnce(testCtx)) + + offered := testPolicyLog.appended(mark) + require.Len(t, offered, 1, "one withdrawal with a live target is one appended entry") + entry := offered[0] + require.Equal(t, business.PolicyLogNarrowed, entry.Decision) + require.Equal(t, opened.ID, entry.SubjectID, + "the subject is the target identity whose consent period ended") + require.Equal(t, uint64(5), entry.EnvelopeRevision, + "the entry names the generation the withdrawal was decided against") + require.Equal(t, "closed", entry.Policy["status"]) + require.Equal(t, "revoked", entry.Policy["installations"]) + requireCommitted(t, testCtx, entry.OperationID) + + require.Nil(t, liveTarget(t, present.Binding), "the narrowing really applied") +} + +// A withdrawal for a binding with NO live target appends nothing. +// +// There is no period to end, so there is no narrowing to witness — and an entry +// here would be a logged narrowing that narrowed nothing, a gap that nothing +// could ever close by re-running the operation. +func TestAWithdrawalWithNoLiveTargetAppendsNothing(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + present := declaredBinding(t, solutionID, 2) + mount := &deliveredSet{} + mount.put(t, present) + reconciler := newTestReconciler(t, mount) + require.NoError(t, reconciler.RunOnce(testCtx)) + + mount.put(t, tombstoneOf(t, present, 3)) + require.NoError(t, reconciler.RunOnce(testCtx)) + require.Nil(t, liveTarget(t, present.Binding)) + + // A second withdrawal at a higher generation: nothing is live any more. + mark := testPolicyLog.mark() + mount.put(t, tombstoneOf(t, present, 4)) + require.NoError(t, reconciler.RunOnce(testCtx)) + require.Empty(t, testPolicyLog.appended(mark), + "a withdrawal with nothing live to close must not append an entry nothing could close") +} diff --git a/module/services/accounts/code/pkg/business/policy_log_test.go b/module/services/accounts/code/pkg/business/policy_log_test.go new file mode 100644 index 000000000..2ee07ee0d --- /dev/null +++ b/module/services/accounts/code/pkg/business/policy_log_test.go @@ -0,0 +1,546 @@ +//go:build pure + +package business + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// The policy log protocol, and every way a host could end up serving authority +// the log says was revoked. +// +// These run on a fake log and a fake local store, in-package, because what is +// under test is the ORDERING and the failure handling — which of the two writes +// happens first, and what is true after each one fails. A database would add +// nothing to that and would hide the interleavings behind a transaction. + +// noopControlPlaneStore satisfies the Store interface by embedding it, so every +// method exists and only the one these tests reach is implemented. +// +// Embedding rather than writing out the whole interface: a hand-written stub of +// a large interface has to be updated whenever the interface grows, and the +// update is mechanical, so it gets done without thought. An embedded nil panics +// loudly if a test reaches a method it did not mean to — which is the behaviour +// worth having, since these tests are about ordering and not about persistence. +type noopControlPlaneStore struct{ Store } + +func (noopControlPlaneStore) WithControlPlane(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} + +// fakePolicyLog records what it was asked and can be made to fail or hang. +type fakePolicyLog struct { + appends []*PolicyLogEntry + receipts map[string]*PolicyLogReceipt + nextSeq uint64 + + appendErr error + entriesErr error + // forceReceipt makes Append return exactly this, including nil, so a + // malformed receipt is testable without pre-seeding the idempotency map — + // which a first draft tried and could not express "returns nil". + forceReceipt *PolicyLogReceipt + forceReceiptSet bool + entries []*PolicyLogRecord + // block makes Append wait, so the bounded deadline is testable. + block time.Duration +} + +func newFakePolicyLog() *fakePolicyLog { + return &fakePolicyLog{receipts: map[string]*PolicyLogReceipt{}} +} + +func (f *fakePolicyLog) Append(ctx context.Context, entry *PolicyLogEntry) (*PolicyLogReceipt, error) { + if f.block > 0 { + select { + case <-time.After(f.block): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if f.appendErr != nil { + return nil, f.appendErr + } + if f.forceReceiptSet { + return f.forceReceipt, nil + } + // Idempotent on the operation id, as the real log must be: a retry returns + // the original receipt rather than appending again. + if existing, ok := f.receipts[entry.OperationID]; ok { + return existing, nil + } + f.nextSeq++ + receipt := &PolicyLogReceipt{ + Receipt: "receipt-" + entry.OperationID, + Sequence: f.nextSeq, + AppendedAt: time.Unix(1700000000, 0).UTC(), + } + f.receipts[entry.OperationID] = receipt + f.appends = append(f.appends, entry) + return receipt, nil +} + +func (f *fakePolicyLog) Entries(_ context.Context, after uint64, _ int) ([]*PolicyLogRecord, error) { + if f.entriesErr != nil { + return nil, f.entriesErr + } + var out []*PolicyLogRecord + for _, record := range f.entries { + if record.Sequence > after { + out = append(out, record) + } + } + return out, nil +} + +// fakePolicyLogStore is the local half, with hooks to fail the commit. +type fakePolicyLogStore struct { + appended map[string]*PolicyLogGap + committed map[string]time.Time + sequence uint64 + reachedAt *time.Time + + commitErr error + appendErr error +} + +func newFakePolicyLogStore() *fakePolicyLogStore { + return &fakePolicyLogStore{ + appended: map[string]*PolicyLogGap{}, + committed: map[string]time.Time{}, + } +} + +func (f *fakePolicyLogStore) RecordPolicyLogAppend( + _ context.Context, receipt *PolicyLogReceipt, entry *PolicyLogEntry, +) (bool, error) { + if f.appendErr != nil { + return false, f.appendErr + } + if _, ok := f.appended[entry.OperationID]; ok { + return false, nil + } + f.appended[entry.OperationID] = &PolicyLogGap{ + OperationID: entry.OperationID, + Sequence: receipt.Sequence, + SubjectKind: entry.SubjectKind, + SubjectID: entry.SubjectID, + AppendedAt: receipt.AppendedAt, + } + return true, nil +} + +func (f *fakePolicyLogStore) CommitPolicyLogOperation(_ context.Context, id string, at time.Time) error { + if f.commitErr != nil { + return f.commitErr + } + if _, ok := f.appended[id]; !ok { + return errors.New("no append recorded for " + id) + } + if _, already := f.committed[id]; !already { + f.committed[id] = at + } + return nil +} + +func (f *fakePolicyLogStore) UncommittedPolicyLogOperations(_ context.Context) ([]*PolicyLogGap, error) { + gaps := []*PolicyLogGap{} + for id, gap := range f.appended { + if _, done := f.committed[id]; !done { + gaps = append(gaps, gap) + } + } + return gaps, nil +} + +func (f *fakePolicyLogStore) PolicyLogCursorState(_ context.Context) (uint64, *time.Time, error) { + return f.sequence, f.reachedAt, nil +} + +func (f *fakePolicyLogStore) AdvancePolicyLogCursor(_ context.Context, sequence uint64, at time.Time) error { + if sequence > f.sequence { + f.sequence = sequence + } + stamp := at + f.reachedAt = &stamp + return nil +} + +// policyLogService wires a service with both fakes and a frozen clock. +// +// A frozen clock, not a real one: the staleness window is a duration, and a test +// that waited for real time would either be slow or would assert on a window it +// had not actually crossed. +func policyLogService(t *testing.T, log PolicyLog, store PolicyLogStore, now time.Time) *Service { + t.Helper() + service := &Service{store: noopControlPlaneStore{}} + service.SetPolicyLog(log, store) + service.policyClock = func() time.Time { return now } + return service +} + +func narrowing(id string) *PolicyLogEntry { + return &PolicyLogEntry{ + OperationID: id, + Decision: PolicyLogNarrowed, + SubjectKind: PolicyLogPrincipal, + SubjectID: "principal-1", + Actor: "admin-1", + } +} + +// --------------------------------------------------------------------------- +// The ordering: append BEFORE anything narrows +// --------------------------------------------------------------------------- + +// The happy path appends, records, applies and commits — and the APPLY must not +// have run before the append landed. +func TestNarrowingAppendsBeforeItApplies(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + now := time.Unix(1700000100, 0).UTC() + service := policyLogService(t, log, store, now) + // A serving host has reached the log. Without this the MayServe assertion + // below would pass for the WRONG reason — staleness rather than the gap — + // and a first draft of this test failed here, which is the implementation + // refusing to let the test lie about why it was happy. + store.reachedAt = &now + + applied := false + require.NoError(t, service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { + // By the time the narrowing runs, the log must already have + // witnessed it: otherwise a crash here leaves authority reduced + // with nothing to say so. + require.Len(t, log.appends, 1, "the append must precede the narrowing") + require.Contains(t, store.appended, "op-1", "the receipt must be recorded before the narrowing") + applied = true + return nil + })) + require.True(t, applied) + require.Contains(t, store.committed, "op-1") + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.True(t, mayServe) + require.Empty(t, state.Gaps) +} + +// An append that FAILS narrows nothing. Safe, because authority was not reduced +// and nobody was told it was. +func TestNarrowingIsRefusedWhenTheAppendFails(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + log.appendErr = errors.New("warehouse unavailable") + service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) + + applied := false + err := service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { + applied = true + return nil + }) + require.ErrorIs(t, err, ErrPolicyLogUnreachable) + require.False(t, applied, "nothing may narrow when the log did not witness it") + require.Empty(t, store.appended) +} + +// A log that returns NO usable receipt has witnessed nothing, whatever it +// returned. Storing an empty receipt would make "witnessed" and "attempted" +// indistinguishable afterwards. +func TestNarrowingIsRefusedWhenTheLogReturnsNoReceipt(t *testing.T) { + for name, receipt := range map[string]*PolicyLogReceipt{ + "nothing at all": nil, + "no token": {Sequence: 1}, + "no sequence": {Receipt: "r"}, + } { + t.Run(name, func(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + log.forceReceipt, log.forceReceiptSet = receipt, true + service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) + + applied := false + err := service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { + applied = true + return nil + }) + require.ErrorIs(t, err, ErrPolicyLogUnreachable) + require.False(t, applied, "a narrowing the log did not witness must not apply") + require.Empty(t, store.appended, "and no receipt may be recorded for it") + }) + } +} + +// --------------------------------------------------------------------------- +// The asymmetry: append-ok / commit-fail must FAIL CLOSED +// --------------------------------------------------------------------------- + +// The central case. The log says the narrowing happened; this host did not apply +// it; so this host MUST STOP SERVING until the gap is closed. +// +// Serving the wider authority it still holds locally would be serving authority +// that was revoked — and the log, which is the record, has already said so. +func TestAppendedButUncommittedNarrowingStopsServing(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + store.commitErr = errors.New("database went away mid-transaction") + now := time.Unix(1700000100, 0).UTC() + service := policyLogService(t, log, store, now) + // The log has been reached recently, so staleness is NOT what refuses here. + store.reachedAt = &now + + err := service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { return nil }) + require.Error(t, err) + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.False(t, mayServe, "a host with an unapplied logged narrowing must not serve") + require.Len(t, state.Gaps, 1) + require.Equal(t, "op-1", state.Gaps[0].OperationID) + require.False(t, state.Unreachable, "the refusal must be the GAP, not staleness") +} + +// Once the gap is closed, serving resumes. Without this the first test could +// pass against a host that never serves at all. +func TestServingResumesWhenTheGapIsClosed(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + store.commitErr = errors.New("transient") + now := time.Unix(1700000100, 0).UTC() + service := policyLogService(t, log, store, now) + store.reachedAt = &now + + require.Error(t, service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { return nil })) + mayServe, _, _ := service.MayServe(context.Background()) + require.False(t, mayServe) + + // The retry succeeds: the log returns the SAME receipt, and the commit lands. + store.commitErr = nil + require.NoError(t, service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { return nil })) + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.True(t, mayServe) + require.Empty(t, state.Gaps) + require.Len(t, log.appends, 1, "a retry of one operation must append ONCE") +} + +// A narrowing whose APPLY fails leaves the gap too, and that is deliberate: the +// log has witnessed the operation, so until this host either applies it or an +// operator resolves it, the host must not serve the authority the log says is +// gone. +func TestFailedApplyAlsoStopsServing(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + now := time.Unix(1700000100, 0).UTC() + service := policyLogService(t, log, store, now) + store.reachedAt = &now + + require.Error(t, service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { + return errors.New("the narrowing itself failed") + })) + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.False(t, mayServe) + require.Len(t, state.Gaps, 1) +} + +// --------------------------------------------------------------------------- +// Refuse to serve while the log is unreachable +// --------------------------------------------------------------------------- + +// A host that has never reached the log does not know whether its authority is +// current, so it does not serve. +func TestNeverHavingReachedTheLogStopsServing(t *testing.T) { + service := policyLogService(t, newFakePolicyLog(), newFakePolicyLogStore(), + time.Unix(1700000100, 0).UTC()) + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.False(t, mayServe) + require.True(t, state.Unreachable) + require.Nil(t, state.LastReachedAt) +} + +// Having reached it too long ago is the same answer. Serving what the host last +// believed is serving authority that may have been revoked since. +func TestStaleLogContactStopsServing(t *testing.T) { + store := newFakePolicyLogStore() + now := time.Unix(1700000100, 0).UTC() + stale := now.Add(-PolicyLogStaleAfter - time.Second) + store.reachedAt = &stale + service := policyLogService(t, newFakePolicyLog(), store, now) + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.False(t, mayServe) + require.True(t, state.Unreachable) +} + +// Inside the window it serves. The window is not zero deliberately: a log round +// trip per request would make the log's availability the host's own, and a blip +// would become an outage. +func TestRecentLogContactServes(t *testing.T) { + store := newFakePolicyLogStore() + now := time.Unix(1700000100, 0).UTC() + fresh := now.Add(-PolicyLogStaleAfter + time.Second) + store.reachedAt = &fresh + service := policyLogService(t, newFakePolicyLog(), store, now) + + mayServe, state, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.True(t, mayServe) + require.False(t, state.Unreachable) +} + +// --------------------------------------------------------------------------- +// The bounded deadline +// --------------------------------------------------------------------------- + +// A log that hangs costs a refusal, not an indefinite wait. The point is lock +// accumulation: the append happens before any transaction opens, so a slow log +// cannot hold database locks — but a caller blocked forever still holds its +// request and everything above it. +func TestASlowLogRefusesRatherThanHanging(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + log.block = PolicyLogDeadline + 2*time.Second + service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) + + applied := false + started := time.Now() + err := service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { + applied = true + return nil + }) + require.ErrorIs(t, err, ErrPolicyLogUnreachable) + require.False(t, applied, "a narrowing the log did not witness must not apply") + require.Less(t, time.Since(started), PolicyLogDeadline+time.Second, + "the append must be bounded by PolicyLogDeadline") + require.Empty(t, store.appended) +} + +// --------------------------------------------------------------------------- +// Reconciliation finds a gap another replica opened +// --------------------------------------------------------------------------- + +// A narrowing appended by ANOTHER replica — one this host never performed — +// becomes a gap here, and stops this host serving until it is applied. +// +// This is the requirement that a startup-only check cannot meet: the entry +// appears in the log at runtime, and every replica must honour it. +func TestReconciliationFindsAnotherReplicasAppend(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + now := time.Unix(1700000100, 0).UTC() + service := policyLogService(t, log, store, now) + store.reachedAt = &now + + mayServe, _, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.True(t, mayServe, "the control: nothing is outstanding yet") + + // Another replica appended a revocation this host knows nothing about. + log.entries = []*PolicyLogRecord{{ + PolicyLogEntry: *narrowing("op-elsewhere"), + Receipt: "receipt-op-elsewhere", + Sequence: 7, + LoggedAt: now, + }} + + state, err := service.ReconcilePolicyLog(context.Background()) + require.NoError(t, err) + require.Len(t, state.Gaps, 1) + require.Equal(t, "op-elsewhere", state.Gaps[0].OperationID) + + mayServe, _, err = service.MayServe(context.Background()) + require.NoError(t, err) + require.False(t, mayServe, "a narrowing another replica logged must stop this host too") + require.Equal(t, uint64(7), store.sequence, "the cursor advances past what was recorded") +} + +// An unreachable log does NOT advance the cursor, so the staleness window closes +// and the host stops serving. A reconciler that advanced the cursor on failure +// would make an unreachable log look like a quiet one. +func TestReconciliationDoesNotAdvanceTheCursorWhenTheLogIsUnreachable(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + log.entriesErr = errors.New("warehouse unavailable") + now := time.Unix(1700000100, 0).UTC() + store.reachedAt = &now + store.sequence = 3 + service := policyLogService(t, log, store, now) + + _, err := service.ReconcilePolicyLog(context.Background()) + require.ErrorIs(t, err, ErrPolicyLogUnreachable) + require.Equal(t, uint64(3), store.sequence, "the cursor must not move when the log was not read") + require.Equal(t, &now, store.reachedAt, "nor may reached_at, or staleness would never trigger") +} + +// --------------------------------------------------------------------------- +// What may be logged +// --------------------------------------------------------------------------- + +// An entry the log could not be replayed from must not reach the log: a bad +// entry that is accepted is worse than a refused operation, because it is a gap +// nothing can close. +func TestUnreplayableEntriesAreRefusedBeforeTheyReachTheLog(t *testing.T) { + for name, entry := range map[string]*PolicyLogEntry{ + "no operation id": {Decision: PolicyLogNarrowed, SubjectKind: PolicyLogPrincipal, SubjectID: "p", Actor: "a"}, + "no actor": {OperationID: "op", Decision: PolicyLogNarrowed, SubjectKind: PolicyLogPrincipal, SubjectID: "p"}, + "no subject": {OperationID: "op", Decision: PolicyLogNarrowed, SubjectKind: PolicyLogPrincipal, Actor: "a"}, + "unknown kind": {OperationID: "op", Decision: PolicyLogNarrowed, SubjectKind: "other", SubjectID: "p", Actor: "a"}, + "unknown decision": {OperationID: "op", Decision: "deleted", SubjectKind: PolicyLogPrincipal, + SubjectID: "p", Actor: "a"}, + } { + t.Run(name, func(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + service := policyLogService(t, log, store, time.Unix(1700000100, 0).UTC()) + require.Error(t, service.WithPolicyLoggedNarrowing( + context.Background(), entry, func(context.Context) error { return nil })) + require.Empty(t, log.appends, "a malformed entry must not reach the log") + }) + } +} + +// An authorised REGRANT is logged too. Without it the log cannot tell "this +// authority was restored" from "it was never taken away", and those have +// different answers to whether the subject may act now. +func TestRegrantsAreLoggedAsWell(t *testing.T) { + log, store := newFakePolicyLog(), newFakePolicyLogStore() + now := time.Unix(1700000100, 0).UTC() + service := policyLogService(t, log, store, now) + + entry := narrowing("op-regrant") + entry.Decision = PolicyLogRegranted + require.NoError(t, service.WithPolicyLoggedNarrowing( + context.Background(), entry, func(context.Context) error { return nil })) + require.Len(t, log.appends, 1) + require.Equal(t, PolicyLogRegranted, log.appends[0].Decision) +} + +// A host with NO log refuses to narrow, because the narrowing would be +// unwitnessed and a restore would undo it silently — while still serving, because +// nothing has been narrowed through the protocol and there is nothing +// unreconciled. The two answers look inconsistent and are not. +func TestWithoutALogNarrowingIsRefusedButServingContinues(t *testing.T) { + service := &Service{store: noopControlPlaneStore{}} + service.policyClock = func() time.Time { return time.Unix(1700000100, 0).UTC() } + + applied := false + err := service.WithPolicyLoggedNarrowing( + context.Background(), narrowing("op-1"), func(context.Context) error { + applied = true + return nil + }) + require.ErrorIs(t, err, ErrPolicyLogUnreachable) + require.False(t, applied) + + mayServe, _, err := service.MayServe(context.Background()) + require.NoError(t, err) + require.True(t, mayServe) +} diff --git a/module/services/accounts/code/pkg/business/principal_directory_cutover_test.go b/module/services/accounts/code/pkg/business/principal_directory_cutover_test.go new file mode 100644 index 000000000..6cf58035a --- /dev/null +++ b/module/services/accounts/code/pkg/business/principal_directory_cutover_test.go @@ -0,0 +1,37 @@ +package business + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListPrincipals_DoesNotProjectDeclaredAuthority(t *testing.T) { + const org = "019f6bf7-6a01-7001-8001-0000000000c1" + registry, err := ParseModulePrincipalRegistry(`{ + "example": {"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + org + `"}, + "worker": {"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"019f6bf7-6a01-7001-8001-0000000000c2","cross_tenant":true} + }`) + require.NoError(t, err) + stored := &Principal{ID: "019f6bf7-6a01-7001-8001-0000000000a1", Kind: PrincipalKindService, OrgID: org, DisplayName: "Example service"} + for _, kind := range []string{"", PrincipalKindService, PrincipalKindHuman} { + for _, cursor := range []string{"", "next-page"} { + t.Run(kind+"/"+cursor, func(t *testing.T) { + store := &pagedPrincipalStore{rows: []*Principal{stored}, next: "more"} + svc, err := NewService(store) + require.NoError(t, err) + svc.SetModulePrincipals(registry) + rows, next, err := svc.ListPrincipals(context.Background(), org, kind, 1, cursor) + require.NoError(t, err) + if kind == PrincipalKindHuman { + require.Empty(t, rows) + } else { + require.Equal(t, []*Principal{stored}, rows, "only durable principals belong in the directory") + } + require.Equal(t, "more", next) + require.Equal(t, int32(1), store.gotPageSize, "the store receives the entire page budget") + }) + } + } +} diff --git a/module/services/accounts/code/pkg/business/principals.go b/module/services/accounts/code/pkg/business/principals.go index c651cbbce..9791a0c17 100644 --- a/module/services/accounts/code/pkg/business/principals.go +++ b/module/services/accounts/code/pkg/business/principals.go @@ -23,12 +23,10 @@ import ( "context" "errors" "fmt" - "slices" "strings" "time" "github.com/codefly-dev/core/wool" - "github.com/google/uuid" ) // PrincipalKind values mirror the SQL CHECK constraint and the @@ -394,6 +392,14 @@ func (s *Service) CreateAgentPrincipal(ctx context.Context, req CreateAgentReque } } + // The agent ceiling may only name audiences this host serves (issue #952), for + // the same reason an installation's may not: an audience nothing serves is a + // capability pointing at no consumer. Refused at write and narrowed at read — + // see Service.RequireHostAudiences. + if err := s.RequireHostAudiences(ctx, req.AllowedAudiences); err != nil { + return nil, NewStoreError(err, ErrTypeValidation) + } + // Generate ID server-side. We don't reuse user/api_key IDs for // agents — agents are net-new principals. p := &Principal{ @@ -437,6 +443,30 @@ func (s *Service) CreateAgentPrincipal(ctx context.Context, req CreateAgentReque // Revoking a principal does not touch the matching `users` row: the two // lifecycles are still separate, which is a deliberate gap kept for code that // reads users directly. +// +// It is a NARROWING — the identity itself stops being able to act — so it runs +// under the policy log: the entry is appended to the external record and +// receipted BEFORE the row is revoked, and the revocation commits in the same +// transaction as the receipt's commit. A host that cannot witness the append +// refuses rather than revoking unwitnessed. +// +// WHAT CHANGED ABOUT THE TRANSACTION, and what did not. Before this, the write +// and its audit event ran in the scope the principal's own row belongs to: a +// tenant transaction for an org-scoped principal, the control plane for an +// org-less human (withPrincipalScope says why the two are not interchangeable). +// The receipt relation is control-plane only and the receipt has to commit with +// the revocation, so both now run under the control plane. The org-less half is +// unchanged; the org-scoped half loses the tenant policy, and what replaces it +// is that every statement here names the principal by id — GetPrincipal and +// RevokePrincipal are id point-writes, not scans — and the audit row carries +// the organisation read back from the principal itself rather than one a caller +// supplied. +// +// The scope lookup stays OUTSIDE the wrapper, deliberately: the entry has to +// name the organisation before the append, and the append happens before any +// transaction opens. Opening one inside the wrapper's transaction is not an +// option either — WithPolicyLoggedNarrowing's apply already runs in a +// control-plane transaction and tenant_tx.go forbids nesting a second. func (s *Service) RevokePrincipal(ctx context.Context, id, reason string) error { w := wool.Get(ctx).In("RevokePrincipal", wool.Field("principal_id", id)) @@ -448,32 +478,64 @@ func (s *Service) RevokePrincipal(ctx context.Context, id, reason string) error // readable reason being recorded. return w.NewError("reason required (no silent revocations)") } - // The write and its audit event share one transaction, in the scope this - // principal's row belongs to (see withPrincipalScope). - if err := s.withPrincipalScope(ctx, id, func(ctx context.Context, orgID string) error { - // Re-read inside that transaction so "did this call change anything" and - // the write itself are one read-modify-write, and an idempotent repeat - // emits no duplicate audit event. - p, e := s.principalStore().GetPrincipal(ctx, id) - if e != nil { - return e - } - alreadyRevoked := p.IsRevoked() - if err := s.principalStore().RevokePrincipal(ctx, id, reason); err != nil { - return err - } - if alreadyRevoked { - return nil - } - return s.emitTx(ctx, "system", "system", EventPrincipalRevoked, "principal", id, orgID, - map[string]any{"reason": reason}) - }); err != nil { + orgID, err := s.principalOrgID(ctx, id) + if err != nil { + return w.Wrapf(err, "cannot resolve principal scope") + } + // The actor is "system" because this surface takes none: it is reached by + // operators and by internal incident paths, and it has always recorded its + // audit event as the system. The log refuses an entry with no actor, so the + // same attribution is stated rather than left empty. + if err := s.WithPolicyLoggedNarrowing(ctx, + revokePrincipalPolicyLogEntry("system", id, orgID, reason), + func(ctx context.Context) error { + // Re-read inside that transaction so "did this call change anything" + // and the write itself are one read-modify-write, and an idempotent + // repeat emits no duplicate audit event. + p, e := s.principalStore().GetPrincipal(ctx, id) + if e != nil { + return e + } + alreadyRevoked := p.IsRevoked() + if err := s.principalStore().RevokePrincipal(ctx, id, reason); err != nil { + return err + } + if alreadyRevoked { + return nil + } + return s.emitTx(ctx, "system", "system", EventPrincipalRevoked, "principal", id, orgID, + map[string]any{"reason": reason}) + }); err != nil { return w.Wrapf(err, "cannot revoke principal") } w.Info("principal revoked", wool.Field("reason", reason)) return nil } +// principalOrgID reads the organisation a principal's row belongs to, "" for an +// org-less human principal (principals_org_scope). It is the first half of what +// withPrincipalScope does, split out for a caller that needs the organisation +// BEFORE it opens the transaction it will write in — a policy-logged narrowing +// has to name its subject's organisation in the entry it appends, and the append +// happens before any transaction opens. +// +// The lookup spans tenants by necessity, as withPrincipalScope's does: the +// caller has no organisation yet, and it reads nothing else. +func (s *Service) principalOrgID(ctx context.Context, id string) (string, error) { + var orgID string + if err := s.store.As(System()).Within(ctx, func(ctx context.Context) error { + p, err := s.principalStore().GetPrincipal(ctx, id) + if err != nil { + return err + } + orgID = p.OrgID + return nil + }); err != nil { + return "", err + } + return orgID, nil +} + // DisableAgentPrincipal reversibly suspends an agent principal. Idempotent: // re-disabling preserves the original disabled_at/reason and emits no duplicate // audit event. The suspension bumps the org/principal authorization revision, so @@ -561,19 +623,7 @@ func (s *Service) EnableAgentPrincipal(ctx context.Context, id string) error { // service / agent. Other values return an error rather than a // silent empty list (helps debug typos). // -// The composed modules that act in the org are listed too, as service -// principals named by their prefix, ahead of the stored rows on the first page -// (the one requested with no page token). A module principal is derived and -// declared rather than stored, so without this it acts, holds grants and is -// recorded as the actor of audit rows while every directory built from this -// listing reports it as unknown. The first page is the one every directory walk -// reads — a walk that stops after a bounded number of pages never reaches a -// large org's last page — and module rows are not part of the stored rows' sort -// key, so leading with them cannot disturb the cursor. They come out of that -// page's own budget, so the page still honours pageSize and the token still -// describes exactly the stored rows the store returned; only an org declaring -// more modules than pageSize exceeds it, because a module left out of the -// listing is an actor no consumer can name. +// Only durable principal rows are listed; declared authority creates no directory entry. func (s *Service) ListPrincipals(ctx context.Context, orgID, kind string, pageSize int32, pageToken string) ([]*Principal, string, error) { w := wool.Get(ctx).In("ListPrincipals", wool.Field("org_id", orgID), @@ -593,43 +643,14 @@ func (s *Service) ListPrincipals(ctx context.Context, orgID, kind string, pageSi if pageSize > 200 { pageSize = 200 } - var modules []*Principal - if pageToken == "" && (kind == "" || kind == PrincipalKindService) { - modules = s.modulePrincipalsActingIn(orgID) - } - storeSize := pageSize - int32(len(modules)) - if storeSize < 1 { - storeSize = 1 - } var out []*Principal var next string if err := s.store.As(Identity{OrgID: orgID}).Within(ctx, func(ctx context.Context) error { var e error - out, next, e = s.principalStore().ListPrincipals(ctx, orgID, kind, storeSize, pageToken) + out, next, e = s.principalStore().ListPrincipals(ctx, orgID, kind, pageSize, pageToken) return e }); err != nil { return nil, "", err } - return append(modules, out...), next, nil -} - -// modulePrincipalsActingIn projects the declared module principals that may act -// in orgID — the ones bound to it and the cross-tenant ones — as service -// principals, ordered by prefix so a listing is stable. A grant without a prefix -// has no name to show and is left out. The org id is compared in its canonical -// form, the form ParseModulePrincipalRegistry stores a grant's tenant in, so a -// caller spelling it in uppercase still finds its own modules. -func (s *Service) modulePrincipalsActingIn(orgID string) []*Principal { - if parsed, err := uuid.Parse(orgID); err == nil { - orgID = parsed.String() - } - var out []*Principal - for id, grant := range s.modulePrincipals { - if grant.Prefix == "" || (grant.Tenant != orgID && !grant.CrossTenant) { - continue - } - out = append(out, &Principal{ID: id, Kind: PrincipalKindService, DisplayName: grant.Prefix, OrgID: orgID}) - } - slices.SortFunc(out, func(a, b *Principal) int { return strings.Compare(a.DisplayName, b.DisplayName) }) - return out + return out, next, nil } diff --git a/module/services/accounts/code/pkg/business/rls_check_test.go b/module/services/accounts/code/pkg/business/rls_check_test.go index fbf2677bd..b498af93a 100644 --- a/module/services/accounts/code/pkg/business/rls_check_test.go +++ b/module/services/accounts/code/pkg/business/rls_check_test.go @@ -20,12 +20,12 @@ import ( // // WithOrgTx defends against this by SET LOCAL ROLE app_tenant inside // the tx. app_tenant is a non-superuser, non-BYPASSRLS role created -// in migration 24. Inside the tx, current_user reports app_tenant +// in migration 26. Inside the tx, current_user reports app_tenant // and RLS engages; on commit/rollback the role-switch unwinds. // // If THIS test fails, RLS-protected queries silently see other // tenants' rows. The ROOT cause is one of: -// - Migration 24 didn't run (role doesn't exist). +// - Migration 26 didn't run (role doesn't exist). // - WithOrgTx forgot the SET LOCAL ROLE. // - The codefly Postgres plugin started running as a non-superuser // (a future improvement; this test wouldn't break, but the @@ -50,7 +50,7 @@ func TestRLS_WithOrgTx_SwitchesToNonSuperuser(t *testing.T) { AND NOT rolsuper AND NOT rolbypassrls)`).Scan(&exists) })) require.True(t, exists, - "app_tenant role MUST exist as non-superuser non-BYPASSRLS — migration 24 didn't run") + "app_tenant role MUST exist as non-superuser non-BYPASSRLS — migration 26 didn't run") return nil })) } diff --git a/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go b/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go index 3c6104ee1..4bd19328a 100644 --- a/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go +++ b/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go @@ -41,6 +41,8 @@ func TestRPCPolicyInventoryIsCompleteAndClassified(t *testing.T) { "/saas.accounts.v1.APIKeyService/ValidateAPIKey", "/saas.accounts.v1.ClientRegistryService/ListRegisteredClients", "/saas.accounts.v1.IdentityService/ResolveIdentity", + "/saas.accounts.v1.InstallationService/ListInstallations", + "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements", "/saas.accounts.v1.ModuleCapabilitiesService/ListReadableSourceCollections", "/saas.accounts.v1.ModuleCapabilitiesService/CheckWorkContextRecordAccess", "/saas.accounts.v1.ModuleCapabilitiesService/ExchangeDelegatedReadAudience", @@ -79,9 +81,8 @@ func TestRPCPolicyInventoryIsCompleteAndClassified(t *testing.T) { "/saas.accounts.v1.PrincipalService/EnableAgentPrincipal", "/saas.accounts.v1.PrincipalService/GetAgentPrincipal", "/saas.accounts.v1.PrincipalService/GetPrincipal", - "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", + "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", - "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration", "/saas.accounts.v1.UsageService/ConsumeUsage", "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision", diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index 65e579935..5ac27071b 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -13,9 +13,13 @@ import ( "context" "errors" "fmt" + "maps" "strings" "sync" + "sync/atomic" + "time" + "github.com/codefly-dev/core/solutionhost" "github.com/codefly-dev/core/wool" "github.com/google/uuid" ) @@ -91,11 +95,156 @@ type Service struct { newAPIClient func(cfg APIDatasourceConfig, credential string) APIContentClient newCrawlerClient func(cfg CrawlerDatasourceConfig) CrawlerContentClient newUploadClient func(cfg UploadDatasourceConfig, secretAccessKey string) UploadContentClient - newOAuth2Refresh OAuth2RefreshFunc // refreshes an OAuth 2.0 API source's access token - moduleProducer jobs.Producer // request-scoped, transactional outbox producer for the module-facing surface - moduleJobStore jobs.Store // privileged worker store (claim/finalize) for the module-facing surface - modulePrincipals ModulePrincipalRegistry // per-principal capability grants for the module-facing surface - eventTransport events.Transport // domain-event pub/sub transport (transactional outbox + relay); nil denies publish/replay + newOAuth2Refresh OAuth2RefreshFunc // refreshes an OAuth 2.0 API source's access token + moduleProducer jobs.Producer // request-scoped, transactional outbox producer for the module-facing surface + moduleJobStore jobs.Store // privileged worker store (claim/finalize) for the module-facing surface + eventTransport events.Transport // domain-event pub/sub transport (transactional outbox + relay); nil denies publish/replay + + // modulePrincipals holds the per-principal capability grants for the + // module-facing surface, behind an atomic pointer rather than as a bare + // field. + // + // It is replaced, not mutated: every writer swaps a whole registry. A bare + // field made that swap a data race against the eighteen authorization sites + // that read it — unsynchronized, and therefore undefined rather than merely + // stale. Nothing replaces it at runtime TODAY (work.go wires it once at + // boot), so the race is latent; it goes live the moment a platform + // administrator can narrow a module's ceiling without a redeploy, which is + // what the envelope record makes possible. Closing it before the writer + // exists is the cheap order. + // + // The pointed-to registry is the SERVICE'S OWN COPY, and that is the half + // that makes "replaced, not mutated" an enforced property rather than a + // convention. The pointer alone guards the map HEADER: a writer that stored + // its caller's map left the backing map reachable from outside, so the + // caller could keep writing into the registry live readers were indexing — + // a data race the pointer cannot see, because no pointer was swapped. Every + // writer below therefore clones on store, and the exported accessor clones + // on read, so no map the service authorizes against is addressable by + // anything else. + // + // What this does NOT yet give is one immutable snapshot per REQUEST. Each + // read takes the registry as it is at that moment, so a request that + // consults it twice could still straddle a replacement. Threading a + // request-scoped snapshot is the other half, and it lands with the runtime + // writer that makes a mid-request replacement reachable at all — doing it + // now, against a registry nothing replaces, would be eighteen call sites no + // test could hold. + modulePrincipals atomic.Pointer[ModulePrincipalRegistry] + + // declaredAudienceBindings caches the DECLARED, NON-TOMBSTONED solution + // binding ids the host's audience vocabulary is derived from + // (work_context_audience.go). Only this half is cached: the module-prefix half + // comes from modulePrincipals above, which is already an in-memory atomic + // pointer, so composing the set per call keeps it current with no invalidation + // hook on SetModulePrincipals. + declaredAudienceBindings atomic.Pointer[declaredAudienceSnapshot] + // audienceClock is the cache's clock. Overridable because a bound no test can + // move is a bound no test can prove: without it the only way to exercise + // expiry is to sleep for two minutes, which nobody does, so the bound would be + // asserted by its constant rather than by its behaviour. + audienceClock func() time.Time + + // The delivery inbox's wiring. All three or none: ReceiveSolutionDelivery + // fails closed and names the gap when any is absent, because a deployment + // that mounted the endpoint without a verifier must not accept documents. + deliveryVerifier solutionhost.BundleVerifier + deliveryStore SolutionDeliveryStore + deliveryCarrier SolutionDeliveryCarrierAuthorizer + deliveryDomainsBySigner map[string][]string + + // The policy log's wiring: the external append-only record, this host's + // local receipts and cursor, and an overridable clock. + // + // Both nil means no log. WithPolicyLoggedNarrowing then REFUSES — a host + // that cannot witness a narrowing must not perform one — while MayServe + // allows serving, because nothing has been narrowed through the protocol and + // there is therefore nothing unreconciled. Those two answers look + // inconsistent and are not: the first is about reducing authority, the + // second about honouring reductions that were recorded. + policyLog PolicyLog + policyLogStore PolicyLogStore + policyClock func() time.Time + // policyServing is the serving gate's bounded memory of its last + // answer. It lives here rather than in the gate because the gate is + // consulted per request and the answer is per host; see + // policy_log_gate.go for why the reuse window is safe. + policyServing policyLogServingCache + + // The live-authority reads enforcement at use depends on. Nil means this + // host cannot re-read, and AuthorizeModuleCapability then refuses every + // capability rather than authorizing on the declared ceiling alone — which + // is the state enforcement-at-use exists to end. + moduleAuthority ModuleAuthorityStore + moduleInstallations ModuleInstallationResolver + moduleOperationBindings ModuleOperationBindingStore + + // The independent sources execution-bound minting needs. Unset means + // BindExecution answers ErrExecutionUnbound — never a mint that skips the + // check. + executionReviewer ExecutionReviewer + executionAuthority ExecutionAuthority +} + +// SetModuleAuthorityReads wires the live reads every capability decision makes. +// +// Boot wiring. Leaving it unset is fail-closed rather than permissive: a host +// that cannot re-read live authority cannot honour a narrowing, so it declines +// to exercise capabilities at all. +func (s *Service) SetModuleAuthorityReads( + authority ModuleAuthorityStore, bindings ModuleOperationBindingStore, +) { + s.moduleAuthority = authority + s.moduleOperationBindings = bindings +} + +// SetPolicyLog wires the append-only authority record and its local half. +// +// Boot wiring, called once before the service listens. +func (s *Service) SetPolicyLog(log PolicyLog, store PolicyLogStore) { + s.policyLog = log + s.policyLogStore = store +} + +// SetSolutionDelivery wires the delivery endpoint: the bundle verifier, the +// inbox's persistence, the carrier authorizer, and which ownership domains each +// attested signer may deliver under. +// +// The signer-to-domain mapping comes from the SAME document as the identity +// allowlist — the verifier's own policy — rather than from this host's +// environment. When the two were separable, a deployer who could set the +// environment could widen what an accepted signer speaks for without touching +// the independently-delivered policy at all. +// +// Boot wiring, called once before the service listens, so these are plain +// fields: an atomic that is only ever written once reads as a claim that +// something might replace it. +func (s *Service) SetSolutionDelivery( + verifier solutionhost.BundleVerifier, + store SolutionDeliveryStore, + carrier SolutionDeliveryCarrierAuthorizer, + domainsBySigner map[string][]string, +) { + s.deliveryVerifier = verifier + s.deliveryStore = store + s.deliveryCarrier = carrier + s.deliveryDomainsBySigner = maps.Clone(domainsBySigner) +} + +// declaredModules is the module principal registry as it stands right now. +// +// An unset registry reads as empty, which denies every module caller — the same +// fail-closed answer a nil map gave, kept deliberately: a deployment that has +// not declared its modules must authorize none of them, never all of them. +// It does NOT clone: this is the hot authorization path, read at eighteen sites +// per the comment on the field, and the map it returns is the service's own — +// unreachable from outside, and mutated by nothing. Callers index it and must +// not write to it. +func (s *Service) declaredModules() ModulePrincipalRegistry { + if registry := s.modulePrincipals.Load(); registry != nil { + return *registry + } + return ModulePrincipalRegistry{} } // SetModuleCapabilities wires the module-facing capability surface (issue #463): @@ -104,10 +253,17 @@ type Service struct { // and the per-principal registry declaring which queues each module service // principal may use and whether it may act across tenants. Leaving the registry // nil denies every caller (fail-closed). +// +// This is BOOT wiring and the only caller is work.go, which runs it once before +// the service listens. The producer and the job store are therefore plain fields +// written before any request can read them; they are deliberately not behind +// atomics, because nothing replaces them at runtime and an atomic that is only +// ever written once reads as a claim that something might. The registry is the +// one thing a later writer replaces, which is why it alone is a pointer. func (s *Service) SetModuleCapabilities(producer jobs.Producer, store jobs.Store, registry ModulePrincipalRegistry) { s.moduleProducer = producer s.moduleJobStore = store - s.modulePrincipals = registry + s.storeModulePrincipals(registry) } // ModulePrincipals returns the declared registry, and SetModulePrincipals @@ -115,12 +271,33 @@ func (s *Service) SetModuleCapabilities(producer jobs.Producer, store jobs.Store // The composition declares a module's vocabulary — including the permission // resources its content is governed by — so a caller that needs to read or // stand in for that declaration goes through here rather than re-deriving it. +// +// It returns a COPY. A caller that received the live registry could mutate the +// map every authorization site is indexing, which would bypass the atomic +// pointer entirely — nothing swaps, so nothing synchronizes. Copying here costs +// nothing on any real path: the authorization sites read the registry through +// the unexported declaredModules, and this accessor exists for callers that need +// to inspect or stand in for the declaration. func (s *Service) ModulePrincipals() ModulePrincipalRegistry { - return s.modulePrincipals + return maps.Clone(s.declaredModules()) } +// SetModulePrincipals replaces the registry at runtime. It clones, for the same +// reason the accessor does: storing the caller's own map would leave the backing +// map addressable by whoever passed it. func (s *Service) SetModulePrincipals(registry ModulePrincipalRegistry) { - s.modulePrincipals = registry + s.storeModulePrincipals(registry) +} + +// storeModulePrincipals publishes a registry the service alone can reach. +// +// maps.Clone of a nil map is nil, and an unset pointer and a nil registry both +// read as empty through declaredModules — the same fail-closed answer — so a +// writer clearing the registry denies every module caller rather than leaving +// the previous one serving. +func (s *Service) storeModulePrincipals(registry ModulePrincipalRegistry) { + owned := maps.Clone(registry) + s.modulePrincipals.Store(&owned) } // SetModuleEventTransport wires the domain-event pub/sub transport backing diff --git a/module/services/accounts/code/pkg/business/service_catalog_test.go b/module/services/accounts/code/pkg/business/service_catalog_test.go index 2e060c478..fa3c95999 100644 --- a/module/services/accounts/code/pkg/business/service_catalog_test.go +++ b/module/services/accounts/code/pkg/business/service_catalog_test.go @@ -2,6 +2,8 @@ package business_test import ( "os" + "regexp" + "strconv" "testing" "github.com/stretchr/testify/require" @@ -22,8 +24,18 @@ func TestServiceCatalogCompilation(t *testing.T) { require.Equal(t, "accounts", catalog.GetOwner().GetService()) require.Equal(t, "saas.accounts.v1", catalog.GetApiPackage()) require.Equal(t, business.ServiceVersion, catalog.GetApiVersion()) - require.Len(t, catalog.GetServices(), 35) - require.Len(t, catalog.GetMethods(), 250) + // THE INVENTORY IS NEVER TYPED. These were `require.Len(..., 35)` and + // `require.Len(..., 246)`, and a merge proved why that is the wrong + // mechanism: the base said 245/35, one branch said 247/36 and the other + // 246/35, each correct against its own tree, and ANY side picked during a + // conflict resolution is silently wrong because the real total is the base + // plus both sets of additions. A hand-maintained number cannot survive two + // people adding an RPC. + // + // So the catalog is the source and the DOC is held to it. Nothing here + // states a count; a drift in either direction fails the build and names + // both numbers. + requireAuthzMatrixMatchesTheCatalog(t, len(catalog.GetServices()), len(catalog.GetMethods())) require.Len(t, catalog.GetPermissions(), 24) require.Len(t, catalog.GetEntitlements(), 5) require.Equal(t, "*:*", catalog.GetPermissions()[0].GetPermission()) @@ -40,6 +52,10 @@ func TestServiceCatalogCompilation(t *testing.T) { require.NotNil(t, method.GetPolicy()) } + for _, method := range []string{"PutSolutionRegistration", "DeleteSolutionRegistration"} { + require.NotContains(t, methods, "/saas.accounts.v1.SolutionRegistryService/"+method) + } + readableSources := methods["/saas.accounts.v1.ModuleCapabilitiesService/ListReadableSourceCollections"] require.NotNil(t, readableSources) require.Equal(t, policyv1.Exposure_EXPOSURE_INTERNAL, readableSources.GetPolicy().GetExposure()) @@ -193,3 +209,36 @@ func TestServiceCatalogValidationRejectsConsumerUnsafeDrift(t *testing.T) { invalidEntitlement.Entitlements[0].Kind = catalogv1.EntitlementKind_ENTITLEMENT_KIND_UNSPECIFIED require.ErrorContains(t, business.ValidateServiceCatalog(invalidEntitlement), "incomplete entitlement") } + +// requireAuthzMatrixMatchesTheCatalog holds AUTHZ_MATRIX.md's stated inventory +// to the catalog the code compiles. +// +// The doc is not rendered from the catalog today, so this is the weaker of the +// two mechanisms the rule allows — but it is the one that makes a drift a build +// failure rather than a stale sentence, and it removes the typed number from +// both places at once. If the doc ever becomes generated, delete this and +// compare bytes instead. +func requireAuthzMatrixMatchesTheCatalog(t *testing.T, services, methods int) { + t.Helper() + const doc = "../../../AUTHZ_MATRIX.md" + body, err := os.ReadFile(doc) + require.NoError(t, err, "AUTHZ_MATRIX.md is the stated inventory; without it this check is vacuous") + + stated := regexp.MustCompile(`Inventory: \*\*(\d+) RPCs\*\* across \*\*(\d+) services\*\*`). + FindSubmatch(body) + require.NotNil(t, stated, + "%s no longer states its inventory in the form this check reads; "+ + "update both together rather than dropping the check", doc) + + statedMethods, err := strconv.Atoi(string(stated[1])) + require.NoError(t, err) + statedServices, err := strconv.Atoi(string(stated[2])) + require.NoError(t, err) + + require.Equal(t, methods, statedMethods, + "%s says %d RPCs; the compiled catalog has %d. The catalog is the source — "+ + "update the doc, and never resolve a merge conflict on this number by picking a side", + doc, statedMethods, methods) + require.Equal(t, services, statedServices, + "%s says %d services; the compiled catalog has %d", doc, statedServices, services) +} diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index 800624ed8..167dcfd73 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -148,6 +148,13 @@ func runBusinessTests(m *testing.M) int { } service.SetWebhookJobProducer(store) + // The policy log's two halves. Every narrowing — uninstall, solution-target + // close, scope-grant revocation, team-membership removal — now REFUSES on a + // host with no log, so a service without one could not exercise any of them. + // The local half is the real postgres store; only the external warehouse is a + // double (policy_log_production_path_test.go says why). + wirePolicyLogForTests(service, store) + // Wire optional components vaultClient, err := infra.NewVaultClient(ctx) if err == nil { diff --git a/module/services/accounts/code/pkg/business/solution_audit_events.go b/module/services/accounts/code/pkg/business/solution_audit_events.go index 1291d9eae..e6da24f93 100644 --- a/module/services/accounts/code/pkg/business/solution_audit_events.go +++ b/module/services/accounts/code/pkg/business/solution_audit_events.go @@ -652,7 +652,7 @@ func (s *Service) admitDeclaredAuditEventTypesWritten(ctx context.Context, solut // id, it is the same entry — and the same `namespaces` — a declared type's // emission is authorized against, so admission can never hand a solution a // namespace it could not emit into. - grant, bound := s.modulePrincipals[ModulePrincipalID(solutionID)] + grant, bound := s.declaredModules()[ModulePrincipalID(solutionID)] if !bound { return nil, nil, fmt.Errorf("%w: %w: solution %q has no module principal entry, so no namespace is bound to it", ErrSolutionAuditDeclarationRejected, ErrSolutionAuditNamespaceUnbound, solutionID) @@ -761,7 +761,7 @@ func (s *Service) releasedAuditNamespace(holder, namespace string) bool { if !ok { return false } - grant, bound := s.modulePrincipals[ModulePrincipalID(solutionID)] + grant, bound := s.declaredModules()[ModulePrincipalID(solutionID)] return !bound || !grant.allowsNamespace(namespace) } diff --git a/module/services/accounts/code/pkg/business/solution_audit_events_integration_test.go b/module/services/accounts/code/pkg/business/solution_audit_events_integration_test.go index 906685595..544c45346 100644 --- a/module/services/accounts/code/pkg/business/solution_audit_events_integration_test.go +++ b/module/services/accounts/code/pkg/business/solution_audit_events_integration_test.go @@ -26,16 +26,37 @@ import ( // database is reused across runs without truncation and audit rows are // append-only, so every run declares a fresh namespace. +func testSolutionID(t *testing.T) string { + t.Helper() + return "sol" + strings.ReplaceAll(uuid.NewString(), "-", "")[:16] +} + func freshAuditNamespace(t *testing.T) string { t.Helper() return "acme_" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12] } -func declaringFrontendWrite(id, namespace string) business.SolutionRegistrationWrite { - return frontendWrite(id, "publisher-"+id, `{"id":"`+id+`","dashboard":{"events":[`+ - `{"name":"created","type":"`+namespace+`.item.created","description":"An item was created.",`+ - `"fields":[{"name":"count","kind":"int"},{"name":"score","kind":"number"},{"name":"stage","kind":"enum","values":["draft","final"]}]},`+ - `{"name":"login","type":"saas.auth.login"}],"metrics":[],"dashboards":[]}}`) +func declaringManifestFor(id, namespace string) string { + return `{"id":"` + id + `","dashboard":{"events":[` + + `{"name":"created","type":"` + namespace + `.item.created","description":"An item was created.",` + + `"fields":[{"name":"count","kind":"int"},{"name":"score","kind":"number"},{"name":"stage","kind":"enum","values":["draft","final"]}]}]}}` +} + +func declareAuditManifest(svc *business.Service, id, manifest string) error { + parsed, err := business.ParseDeclaredAuditEventTypes(id, manifest) + if err != nil { + return err + } + var declarations []business.AuditEventTypeDeclaration + for _, typ := range parsed { + declaration := business.AuditEventTypeDeclaration{Type: string(typ.Type), Description: typ.Description, Visibility: typ.Visibility} + for _, field := range typ.Fields { + declaration.Fields = append(declaration.Fields, business.AuditFieldDeclaration{Name: field.Name, Kind: string(field.Kind), Values: field.Enum, PII: field.PII}) + } + declarations = append(declarations, declaration) + } + _, _, err = svc.ModuleDeclareAuditEventTypes(testCtx, business.ModuleCaller{PrincipalID: business.ModulePrincipalID(id)}, id, declarations) + return err } // declaringService is a service over the package store whose operator binding @@ -47,20 +68,22 @@ func declaringService(t *testing.T, bound map[string][]string) *business.Service require.NoError(t, err) registry := business.ModulePrincipalRegistry{} for solution, namespaces := range bound { - registry[business.ModulePrincipalID(solution)] = business.ModulePrincipalGrant{Prefix: solution, Namespaces: namespaces} + registry[business.ModulePrincipalID(solution)] = business.ModulePrincipalGrant{Prefix: solution, Namespaces: namespaces, CrossTenant: true} } + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModulePrincipals(registry) return svc } -func TestSolutionAuditEvents_AdmittedAtRegistrationAndEmitted(t *testing.T) { +func TestSolutionAuditEvents_DeclaredAndEmitted(t *testing.T) { clearData(t) namespace := freshAuditNamespace(t) solution := testSolutionID(t) eventType := business.EventType(namespace + ".item.created") registrar := declaringService(t, map[string][]string{solution: {namespace}}) - _, err := registrar.PutSolutionRegistration(testCtx, declaringFrontendWrite(solution, namespace)) + err := declareAuditManifest(registrar, solution, declaringManifestFor(solution, namespace)) require.NoError(t, err, "register a solution declaring a typed event") var admitted *business.DeclaredAuditEventType @@ -118,6 +141,7 @@ func TestSolutionAuditEvents_AdmittedAtRegistrationAndEmitted(t *testing.T) { require.NoError(t, err) svc.SetAuditEmitter(emitter) backend := &fakeJobBackend{} + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(backend, backend, business.ModulePrincipalRegistry{ modulePrincSvc: {Namespaces: []string{namespace}}, }) @@ -152,11 +176,11 @@ func TestSolutionAuditEvents_NamespaceBelongsToOneSolution(t *testing.T) { // namespace while its binding stands. registrar := declaringService(t, map[string][]string{first: {namespace}, second: {namespace}}) - _, err := registrar.PutSolutionRegistration(testCtx, declaringFrontendWrite(first, namespace)) + err := declareAuditManifest(registrar, first, declaringManifestFor(first, namespace)) require.NoError(t, err) - _, err = registrar.PutSolutionRegistration(testCtx, declaringFrontendWrite(second, namespace)) - require.True(t, errors.Is(err, business.ErrSolutionAuditNamespaceOwned), "err = %v, want the namespace refused", err) + err = declareAuditManifest(registrar, second, declaringManifestFor(second, namespace)) + require.Equal(t, codes.InvalidArgument, status.Code(err), "namespace admission must fail") // The refusal rolled the whole write back: the second solution has no record. records, _, err := testService.ListSolutionRegistrations(testCtx, true) @@ -165,26 +189,12 @@ func TestSolutionAuditEvents_NamespaceBelongsToOneSolution(t *testing.T) { require.NotEqual(t, second, record.SolutionID, "a refused declaration must not store the registration") } - // Re-registering the owner's identical declaration in a changed manifest is - // idempotent, and a field-dropping change is refused. - var current *business.SolutionRegistration - for _, record := range records { - if record.SolutionID == first { - current = record - } - } - require.NotNil(t, current) - renamed := declaringFrontendWrite(first, namespace) - renamed.Frontend.Manifest = strings.Replace(renamed.Frontend.Manifest, `"id":"`+first+`"`, `"id":"`+first+`","schemaVersion":1`, 1) - renamed.ExpectedRevision = ¤t.Revision - current, err = registrar.PutSolutionRegistration(testCtx, renamed) - require.NoError(t, err, "an identical declaration in a changed manifest") - - dropping := frontendWrite(first, "publisher-"+first, `{"id":"`+first+`","dashboard":{"events":[`+ - `{"name":"created","type":"`+namespace+`.item.created","fields":[{"name":"count","kind":"int"}]}],"metrics":[],"dashboards":[]}}`) - dropping.ExpectedRevision = ¤t.Revision - _, err = registrar.PutSolutionRegistration(testCtx, dropping) - require.True(t, errors.Is(err, business.ErrSolutionAuditDeclarationRejected), "err = %v, want a refused change", err) + // An identical declaration is idempotent; a field-dropping change is refused. + require.NoError(t, declareAuditManifest(registrar, first, declaringManifestFor(first, namespace))) + dropping := `{"id":"` + first + `","dashboard":{"events":[{"name":"created","type":"` + namespace + `.item.created","fields":[{"name":"count","kind":"int"}]}]}}` + err = declareAuditManifest(registrar, first, dropping) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + } // The operator's release path against Postgres: unbinding the namespace from @@ -195,12 +205,10 @@ func TestSolutionAuditEvents_RebindingTransfersTheNamespace(t *testing.T) { holder, successor := testSolutionID(t), testSolutionID(t) eventType := business.EventType(namespace + ".item.created") - _, err := declaringService(t, map[string][]string{holder: {namespace}}). - PutSolutionRegistration(testCtx, declaringFrontendWrite(holder, namespace)) + err := declareAuditManifest(declaringService(t, map[string][]string{holder: {namespace}}), holder, declaringManifestFor(holder, namespace)) require.NoError(t, err) - _, err = declaringService(t, map[string][]string{successor: {namespace}}). - PutSolutionRegistration(testCtx, declaringFrontendWrite(successor, namespace)) + err = declareAuditManifest(declaringService(t, map[string][]string{successor: {namespace}}), successor, declaringManifestFor(successor, namespace)) require.NoError(t, err, "the successor takes over a namespace its holder is no longer bound to") var admitted *business.DeclaredAuditEventType @@ -219,8 +227,7 @@ func TestSolutionAuditEvents_CatalogCollisionFailsTheSync(t *testing.T) { namespace := freshAuditNamespace(t) solution := testSolutionID(t) eventType := business.EventType(namespace + ".item.created") - _, err := declaringService(t, map[string][]string{solution: {namespace}}). - PutSolutionRegistration(testCtx, declaringFrontendWrite(solution, namespace)) + err := declareAuditManifest(declaringService(t, map[string][]string{solution: {namespace}}), solution, declaringManifestFor(solution, namespace)) require.NoError(t, err) for name, colliding := range map[string]business.EventType{ diff --git a/module/services/accounts/code/pkg/business/solution_audit_events_test.go b/module/services/accounts/code/pkg/business/solution_audit_events_test.go index ec0938aaf..799089a86 100644 --- a/module/services/accounts/code/pkg/business/solution_audit_events_test.go +++ b/module/services/accounts/code/pkg/business/solution_audit_events_test.go @@ -16,7 +16,7 @@ import ( // Admission rules for solution-declared audit event types, exercised without a // database: the manifest parser, the additive-change rule, the stored-schema -// round trip, and admission through PutSolutionRegistration against an +// round trip, and declaration admission against an // in-memory store that rolls a failed transaction back. The SQL itself is // covered in solution_audit_events_integration_test.go. @@ -365,21 +365,31 @@ func newDeclaringService(t *testing.T, store *declaredAuditStore, bound bindings if err != nil { t.Fatal(err) } + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(bound.registry()) return svc } -func registerDeclaring(t *testing.T, svc *Service, id string, expected *int64, events string) (*SolutionRegistration, error) { +func admitDeclaring(t *testing.T, svc *Service, id string, events string) ([]string, error) { t.Helper() - write := frontendWrite(id, "publisher-"+id, declaringManifest(id, events)) - write.ExpectedRevision = expected - return svc.PutSolutionRegistration(context.Background(), write) + declared, err := ParseDeclaredAuditEventTypes(id, declaringManifest(id, events)) + if err != nil { + return nil, err + } + var taken []string + err = svc.store.WithControlPlane(context.Background(), func(ctx context.Context) error { + var err error + taken, err = svc.admitDeclaredAuditEventTypes(ctx, id, declared) + return err + }) + return taken, err } -func TestPutSolutionRegistration_AdmitsDeclaredTypes(t *testing.T) { +func TestAuditDeclarationAdmission_AdmitsDeclaredTypes(t *testing.T) { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{"acme": {"acme"}, "other": {"acme"}, "third": {"legacy"}}) - record, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent) + _, err := admitDeclaring(t, svc, "acme", exampleDeclaredEvent) if err != nil { t.Fatalf("register: %v", err) } @@ -394,8 +404,7 @@ func TestPutSolutionRegistration_AdmitsDeclaredTypes(t *testing.T) { // An identical declaration in a changed manifest is idempotent: the // registration advances, the admitted type is not rewritten. puts := store.puts - revision := record.Revision - record, err = registerDeclaring(t, svc, "acme", &revision, + _, err = admitDeclaring(t, svc, "acme", exampleDeclaredEvent+`,{"name":"login","type":"saas.auth.login"}`) if err != nil { t.Fatalf("re-register identical declaration: %v", err) @@ -405,8 +414,7 @@ func TestPutSolutionRegistration_AdmitsDeclaredTypes(t *testing.T) { } // Adding a field is admitted. - revision = record.Revision - record, err = registerDeclaring(t, svc, "acme", &revision, + _, err = admitDeclaring(t, svc, "acme", `{"name":"created","type":"acme.item.created","fields":[{"name":"stage","kind":"enum","values":["draft","final"]},{"name":"score","kind":"number"},{"name":"count","kind":"int"},{"name":"owner_id","kind":"uuid"}]}`) if err != nil { t.Fatalf("additive change: %v", err) @@ -417,21 +425,20 @@ func TestPutSolutionRegistration_AdmitsDeclaredTypes(t *testing.T) { // Dropping a field refuses the whole write: the registration keeps the // revision and manifest it had. - revision = record.Revision - before := store.registrations["acme"] - if _, err := registerDeclaring(t, svc, "acme", &revision, + before := store.rows["acme.item.created"] + if _, err := admitDeclaring(t, svc, "acme", `{"name":"created","type":"acme.item.created","fields":[{"name":"count","kind":"int"}]}`); !errors.Is(err, ErrSolutionAuditDeclarationRejected) { t.Fatalf("dropping a field: err = %v, want rejected", err) } - if after := store.registrations["acme"]; after.Revision != before.Revision || after.Frontend.Manifest != before.Frontend.Manifest { + if after := store.rows["acme.item.created"]; !reflect.DeepEqual(after, before) { t.Fatal("a refused declaration must leave the last admitted registration in place") } } -func TestPutSolutionRegistration_RefusesAnotherProducersNamespace(t *testing.T) { +func TestAuditDeclarationAdmission_RefusesAnotherProducersNamespace(t *testing.T) { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{"acme": {"acme"}, "other": {"acme"}, "third": {"legacy"}}) - if _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", exampleDeclaredEvent); err != nil { t.Fatalf("first solution: %v", err) } // A second solution may neither take the type nor add one beside it. @@ -439,7 +446,7 @@ func TestPutSolutionRegistration_RefusesAnotherProducersNamespace(t *testing.T) exampleDeclaredEvent, `{"name":"deleted","type":"acme.item.deleted","fields":[]}`, } { - _, err := registerDeclaring(t, svc, "other", nil, events) + _, err := admitDeclaring(t, svc, "other", events) if !errors.Is(err, ErrSolutionAuditNamespaceOwned) || !errors.Is(err, ErrSolutionAuditDeclarationRejected) { t.Fatalf("second solution: err = %v, want the namespace refused", err) } @@ -449,14 +456,14 @@ func TestPutSolutionRegistration_RefusesAnotherProducersNamespace(t *testing.T) } // A namespace the code catalog holds is refused the same way. store.rows["legacy.item.created"] = declaredAuditRow{owner: "accounts", namespace: "legacy"} - if _, err := registerDeclaring(t, svc, "third", nil, `{"name":"e","type":"legacy.item.other","fields":[]}`); !errors.Is(err, ErrSolutionAuditNamespaceOwned) { + if _, err := admitDeclaring(t, svc, "third", `{"name":"e","type":"legacy.item.other","fields":[]}`); !errors.Is(err, ErrSolutionAuditNamespaceOwned) { t.Fatalf("code-owned namespace: err = %v, want refused", err) } } // A namespace the composed event catalog publishes domain events under already // has its producer, even though no audit_event_types row names it. -func TestPutSolutionRegistration_RefusesPublishedEventNamespaces(t *testing.T) { +func TestAuditDeclarationAdmission_RefusesPublishedEventNamespaces(t *testing.T) { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{"acme": {"acme"}, "other": {"acme"}, "third": {"legacy"}}) // installation and scope are the platform's own domain-event namespaces; @@ -469,7 +476,7 @@ func TestPutSolutionRegistration_RefusesPublishedEventNamespaces(t *testing.T) { } } for _, namespace := range namespaces { - _, err := registerDeclaring(t, svc, "acme", nil, + _, err := admitDeclaring(t, svc, "acme", `{"name":"e","type":"`+namespace+`.item.created","fields":[]}`) if !errors.Is(err, ErrSolutionAuditNamespaceOwned) || !errors.Is(err, ErrSolutionAuditDeclarationRejected) { t.Fatalf("%s: err = %v, want the namespace refused", namespace, err) @@ -482,33 +489,15 @@ func TestPutSolutionRegistration_RefusesPublishedEventNamespaces(t *testing.T) { } } // A namespace no producer publishes under is still free. - if _, err := registerDeclaring(t, svc, "acme", nil, `{"name":"e","type":"acme.item.created","fields":[]}`); err != nil { + if _, err := admitDeclaring(t, svc, "acme", `{"name":"e","type":"acme.item.created","fields":[]}`); err != nil { t.Fatalf("unpublished namespace: %v", err) } } -// A renewal carries a manifest already admitted and is not re-read, so a -// heartbeat never touches the audit registry. -func TestPutSolutionRegistration_RenewalDoesNotReadmit(t *testing.T) { - store := newDeclaredAuditStore() - svc := newDeclaringService(t, store, bindings{"acme": {"acme"}, "other": {"acme"}, "third": {"legacy"}}) - if _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent); err != nil { - t.Fatalf("register: %v", err) - } - puts := store.puts - delete(store.rows, "acme.item.created") - if _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent); err != nil { - t.Fatalf("renewal: %v", err) - } - if store.puts != puts { - t.Fatal("a lease renewal must not re-admit declared types") - } -} - func TestAuditEventTypes_ListsCatalogAndDeclaredTypes(t *testing.T) { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{"acme": {"acme"}, "other": {"acme"}, "third": {"legacy"}}) - if _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", exampleDeclaredEvent); err != nil { t.Fatalf("register: %v", err) } definitions, err := svc.AuditEventTypes(context.Background()) @@ -535,7 +524,7 @@ func TestAuditEventTypes_ListsCatalogAndDeclaredTypes(t *testing.T) { // A solution admits types only into the namespaces the operator bound to it, // so a registration credential alone claims nothing. -func TestPutSolutionRegistration_RefusesAnUnboundNamespace(t *testing.T) { +func TestAuditDeclarationAdmission_RefusesAnUnboundNamespace(t *testing.T) { for name, bound := range map[string]bindings{ "no principal entry": {}, "entry binds no namespace": {"acme": nil}, @@ -543,7 +532,7 @@ func TestPutSolutionRegistration_RefusesAnUnboundNamespace(t *testing.T) { } { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bound) - _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent) + _, err := admitDeclaring(t, svc, "acme", exampleDeclaredEvent) if !errors.Is(err, ErrSolutionAuditNamespaceUnbound) || !errors.Is(err, ErrSolutionAuditDeclarationRejected) { t.Fatalf("%s: err = %v, want the unbound namespace refused", name, err) } @@ -554,7 +543,7 @@ func TestPutSolutionRegistration_RefusesAnUnboundNamespace(t *testing.T) { // A manifest that declares nothing needs no binding. store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{}) - if _, err := registerDeclaring(t, svc, "acme", nil, `{"name":"login","type":"saas.auth.login"}`); err != nil { + if _, err := admitDeclaring(t, svc, "acme", `{"name":"login","type":"saas.auth.login"}`); err != nil { t.Fatalf("a fieldless declaration: %v", err) } } @@ -562,10 +551,10 @@ func TestPutSolutionRegistration_RefusesAnUnboundNamespace(t *testing.T) { // Ownership follows the binding: once the operator unbinds a namespace from // its holder and binds it to another solution, that solution's next admission // takes every type in it over, and the registration event records it. -func TestPutSolutionRegistration_TakesOverAReleasedNamespace(t *testing.T) { +func TestAuditDeclarationAdmission_TakesOverAReleasedNamespace(t *testing.T) { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{"acme": {"acme"}}) - if _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", exampleDeclaredEvent); err != nil { t.Fatalf("first holder: %v", err) } @@ -576,44 +565,44 @@ func TestPutSolutionRegistration_TakesOverAReleasedNamespace(t *testing.T) { } { store := newDeclaredAuditStore() svc := newDeclaringService(t, store, bindings{"acme": {"acme"}}) - if _, err := registerDeclaring(t, svc, "acme", nil, exampleDeclaredEvent); err != nil { + if _, err := admitDeclaring(t, svc, "acme", exampleDeclaredEvent); err != nil { t.Fatalf("%s: first holder: %v", name, err) } emitter := &recordingEmitter{} svc.SetAuditEmitter(emitter) + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(bound.registry()) // The successor re-declares the type (still additive) and adds one. - if _, err := registerDeclaring(t, svc, "successor", nil, exampleDeclaredEvent+ + if taken, err := admitDeclaring(t, svc, "successor", exampleDeclaredEvent+ `,{"name":"deleted","type":"acme.item.deleted","fields":[]}`); err != nil { t.Fatalf("%s: successor: %v", name, err) + } else if !reflect.DeepEqual(taken, []string{"acme"}) { + t.Fatalf("takeover = %v", taken) } for _, eventType := range []EventType{"acme.item.created", "acme.item.deleted"} { if got := store.rows[eventType].owner; got != "solution:successor" { t.Fatalf("%s: %s owner = %q, want solution:successor", name, eventType, got) } } - var recorded []string - for _, entry := range emitter.entries { - if entry.EventType == EventSolutionRegistrationUpdated { - recorded, _ = entry.Payload["audit_namespaces_taken_over"].([]string) - } - } - if !reflect.DeepEqual(recorded, []string{"acme"}) { - t.Fatalf("%s: takeover recorded as %v, want [acme]", name, recorded) - } + } // While the holder is still bound, a second binding does not release it: // that is a misconfiguration, refused rather than raced. + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(bindings{"acme": {"acme"}, "successor": {"acme"}}.registry()) - if _, err := registerDeclaring(t, svc, "successor", nil, exampleDeclaredEvent); !errors.Is(err, ErrSolutionAuditNamespaceOwned) { + if _, err := admitDeclaring(t, svc, "successor", exampleDeclaredEvent); !errors.Is(err, ErrSolutionAuditNamespaceOwned) { t.Fatalf("holder still bound: err = %v, want refused", err) } // A code-owned namespace is never taken over. store.rows["legacy.item.created"] = declaredAuditRow{owner: "accounts", namespace: "legacy"} + withCurrentAuthority(svc) + withCurrentAuthority(svc) svc.SetModulePrincipals(bindings{"successor": {"legacy"}}.registry()) - if _, err := registerDeclaring(t, svc, "successor", nil, `{"name":"e","type":"legacy.item.other","fields":[]}`); !errors.Is(err, ErrSolutionAuditNamespaceOwned) { + if _, err := admitDeclaring(t, svc, "successor", `{"name":"e","type":"legacy.item.other","fields":[]}`); !errors.Is(err, ErrSolutionAuditNamespaceOwned) { t.Fatalf("code-owned namespace: err = %v, want refused", err) } } diff --git a/module/services/accounts/code/pkg/business/solution_delivery.go b/module/services/accounts/code/pkg/business/solution_delivery.go new file mode 100644 index 000000000..d782f60a5 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_delivery.go @@ -0,0 +1,551 @@ +package business + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/codefly-dev/core/solutionhost" +) + +// The delivery inbox: how a signed document reaches this host, and why it is +// persisted rather than read from a directory. +// +// A mount is the CURRENT desired set and nothing else. A document that arrived, +// was recorded as desired, failed to apply and then disappeared from the mount +// was never retried: the host had recorded that delivery wanted something and +// had no way to want it again. The persisted representation also dropped the +// carrier, so a restore could not re-verify what it had accepted — only trust +// its own earlier judgement. +// +// So delivery POSTs a carrier, the host verifies it ON RECEIPT, and persists the +// bytes. The reconciler then reads its desired set from durable state, which is +// what makes "delivery stopped talking" and "delivery said remove it" different +// facts. Removal stays a tombstone GENERATION; nothing deletes from the inbox. + +// SolutionDeliveryKind is which half of the lifecycle a document is. +// +// The two have different authorised writers and different identities, so they +// are never interchangeable: the kind is part of every lookup and part of the +// carrier authorisation decision. +type SolutionDeliveryKind string + +const ( + // SolutionDeliveryPresence declares what runs where. + SolutionDeliveryPresence SolutionDeliveryKind = "presence" + // SolutionDeliveryAuthority declares what a module instance is approved for. + SolutionDeliveryAuthority SolutionDeliveryKind = "authority" +) + +// Valid reports whether a kind is one this host serves. Checked at the edge, so +// an unknown kind is a 404 on a path rather than a row with a kind nothing reads. +func (kind SolutionDeliveryKind) Valid() bool { + return kind == SolutionDeliveryPresence || kind == SolutionDeliveryAuthority +} + +// SolutionDeliveryCarrier is one received carrier: the canonical payload and the +// attestation over exactly those bytes. +// +// The two travel together and are stored together. A payload without its bundle +// is a document nobody can re-verify, which is the state that made a restore +// unable to re-check what it had accepted. +type SolutionDeliveryCarrier struct { + Kind SolutionDeliveryKind + // Raw is the carrier exactly as delivered — `{schema, document, bundle}` — + // parsed by core's ParseSigned rather than unmarshalled here. Core's + // Signed.validate checks the carrier's own schema, which an ad-hoc + // two-field unmarshal skips: a hand-built carrier claiming any schema used + // to reach the bundle verifier. + Raw []byte +} + +// SolutionDeliveryRecord is one row of the inbox. +type SolutionDeliveryRecord struct { + Kind SolutionDeliveryKind + DocumentID string + Generation uint64 + // ContentHash is sha256 of the canonical payload, `sha256:<64 hex>`. + ContentHash string + Payload []byte + Bundle []byte + // SignerIdentity is what the verifier attested, and OwnershipDomain is what + // the document asserts. Both recorded at receipt so an operator can see who + // delivered what without re-running verification — and so a later narrowing + // of the policy reads as a disagreement with history rather than as a silent + // change in what the host would accept today. + SignerIdentity string + OwnershipDomain string + ReceivedAt time.Time +} + +// SolutionDeliveryDisposition is what the host did with a carrier. Every value +// maps to exactly one response code, and the mapping is the published contract. +type SolutionDeliveryDisposition string + +const ( + // SolutionDeliveryAccepted: verified, new, and now durable desired state — + // whether or not it has been applied yet. 202. + SolutionDeliveryAccepted SolutionDeliveryDisposition = "accepted" + // SolutionDeliveryReplayed: this exact (document, generation, content hash) + // is already durable. Writes nothing. 200. + SolutionDeliveryReplayed SolutionDeliveryDisposition = "replayed" +) + +// Delivery refusals. Each is a distinct fact with a distinct response code, +// because a retry is right for some and wrong for the rest. +var ( + // ErrSolutionDeliveryMalformed: the carrier does not parse, or the payload + // is not the canonical encoding of the document it decodes to. 400, + // terminal. A signer and a host that disagree about which bytes represent + // the document disagree about what was approved. + ErrSolutionDeliveryMalformed = errors.New("solution delivery carrier is malformed") + + // ErrSolutionDeliveryUnattested: the bundle does not verify, or its identity + // is not one this host accepts. 403, terminal. + ErrSolutionDeliveryUnattested = errors.New("solution delivery carrier is not attested by an accepted signer") + + // ErrSolutionDeliveryDomainNotGranted: the attested signer may not deliver + // under the ownership domain the document asserts. 403, terminal. + // + // Distinct from the above on purpose: the signature is genuine and the + // signer is accepted, and what failed is the scope of its authority. An + // operator fixes those two in different places. + ErrSolutionDeliveryDomainNotGranted = errors.New("attested signer may not deliver under this ownership domain") + + // ErrSolutionDeliveryInvalid: the document parses but Validate refuses it. + // 422, terminal. + ErrSolutionDeliveryInvalid = errors.New("solution delivery document is invalid") + + // ErrSolutionDeliveryRewritten: this generation is already durable with + // DIFFERENT bytes. 409, terminal. + // + // It is a conflict rather than a bad request because the operator needs to + // see it as a rewrite of a delivered generation: publish never rewrites one, + // so reaching this means a hand-edited delivery tree. + ErrSolutionDeliveryRewritten = errors.New("solution delivery generation was already delivered with different content") +) + +// SolutionDeliveryStore is the inbox's persistence. +type SolutionDeliveryStore interface { + // RecordDeliveredDocument inserts one received carrier. It reports whether + // the row was new: an exact replay writes nothing and is not an error. + // + // The unique index on (kind, document_id, generation) is what makes a + // rewrite detectable, so the implementation turns that collision into + // ErrSolutionDeliveryRewritten rather than overwriting. + RecordDeliveredDocument(ctx context.Context, record *SolutionDeliveryRecord) (inserted bool, err error) + + // ListNewestDeliveredDocuments returns the newest generation per document + // for one kind — the desired set. + ListNewestDeliveredDocuments(ctx context.Context, kind SolutionDeliveryKind) ([]*SolutionDeliveryRecord, error) +} + +// SolutionDeliveryCarrierAuthorizer decides whether the CALLER may deliver this +// kind of document. +// +// It is separate from bundle verification, and keeping them apart is the point. +// The bundle says who SIGNED the content; this says who HANDED IT OVER. A valid +// bundle does not make every caller a legitimate carrier — otherwise anyone who +// could replay a captured carrier could re-deliver it, including a superseded +// generation whose signature is still perfectly good. +type SolutionDeliveryCarrierAuthorizer interface { + // AuthorizeCarrier authorises the caller's credential for this kind, given + // the namespaces the document's own workloads declare. + // + // The namespaces are a parameter because presence and authority are + // authorised differently: authority is a fixed (service account, namespace) + // pair, while a presence carrier must come from the namespace the document + // itself declares. That makes the presence namespace a property of the + // SIGNED DOCUMENT rather than of host configuration a deployer can edit — + // the same reason the ownership domain lives inside the canonical bytes. + AuthorizeCarrier(ctx context.Context, credential string, kind SolutionDeliveryKind, declaredNamespaces []string) error +} + +// ReceiveSolutionDelivery verifies, authorises and persists one delivered +// carrier. +// +// The ORDER is the contract, and it is signature-first: +// +// 1. parse the carrier (core's ParseSigned, so the carrier's own schema is +// checked); +// 2. verify the bundle against the trust root and the identity allowlist, +// WITH THE READER FOR THE KIND THE PATH NAMES; +// 3. check the attested signer may speak for the domain the document asserts; +// 4. authorise the CALLER as a carrier for this kind; +// 5. persist. +// +// Verification comes before carrier authorisation deliberately. The cheaper +// check is the carrier's, so an efficiency argument would put it first — but +// then an unauthenticated caller's malformed bytes would be refused with a +// message about credentials, and a captured carrier replayed by an authorised +// one would be refused with a message about signatures. Ordering by what the +// operator needs to read beats ordering by cost. +func (s *Service) ReceiveSolutionDelivery( + ctx context.Context, carrier SolutionDeliveryCarrier, credential string, +) (SolutionDeliveryDisposition, *SolutionDeliveryRecord, error) { + if !carrier.Kind.Valid() { + return "", nil, fmt.Errorf("%w: unknown delivery kind %q", ErrSolutionDeliveryMalformed, carrier.Kind) + } + if s.deliveryVerifier == nil || s.deliveryStore == nil || s.deliveryCarrier == nil { + // Fail closed and say which half is missing. A deployment that wired + // the endpoint without a verifier must not accept documents. + return "", nil, fmt.Errorf("%w: the delivery endpoint is not fully wired on this host", ErrSolutionDeliveryUnattested) + } + + signed, err := solutionhost.ParseSigned(carrier.Raw) + if err != nil { + return "", nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) + } + + received, err := s.receiveDeliveredDocument(ctx, carrier.Kind, signed) + if err != nil { + return "", nil, err + } + + // The attested signer must be entitled to the domain the document asserts. + // Checked here as well as by core's Admit, because a document that will be + // refused at admission should not become durable desired state in the first + // place — an inbox of documents nobody may act on is an inbox an operator + // has to triage. + if !s.signerMaySpeakFor(received.signer, received.ownershipDomain) { + return "", nil, fmt.Errorf("%w: signer %q, domain %q", + ErrSolutionDeliveryDomainNotGranted, received.signer, received.ownershipDomain) + } + + if err := s.deliveryCarrier.AuthorizeCarrier( + ctx, credential, carrier.Kind, received.carrierNamespaces); err != nil { + return "", nil, err + } + + record := &SolutionDeliveryRecord{ + Kind: carrier.Kind, + DocumentID: received.documentID, + Generation: received.generation, + ContentHash: solutionDeliveryContentHash(received.payload), + Payload: received.payload, + Bundle: signed.Bundle, + SignerIdentity: received.signer, + OwnershipDomain: received.ownershipDomain, + ReceivedAt: time.Now().UTC(), + } + + var inserted bool + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var storeErr error + inserted, storeErr = s.deliveryStore.RecordDeliveredDocument(ctx, record) + return storeErr + }); err != nil { + return "", nil, err + } + if inserted { + return SolutionDeliveryAccepted, record, nil + } + return SolutionDeliveryReplayed, record, nil +} + +// receivedSolutionDelivery is one verified document read into the shape the +// inbox stores. The two kinds are different documents with different identity +// fields and different carrier rules, and this is where that difference ends: +// everything past it is one row. +type receivedSolutionDelivery struct { + documentID string + generation uint64 + ownershipDomain string + signer string + payload []byte + + // carrierNamespaces is what the CALLER is authorised against. It is empty + // for authority, which has one fixed writer namespace, and for presence it + // is the namespaces the document's own workloads declare — see + // presenceCarrierNamespaces for the generation that declares none. + carrierNamespaces []string +} + +// receiveDeliveredDocument verifies the carrier with the reader for the kind +// the PATH named. +// +// This dispatch is the defect it replaces, not a tidy-up. Both paths called +// VerifyDelivered — the PRESENCE reader — so the two halves of the lifecycle +// were exactly inverted at the endpoint: +// +// - a genuine authority document POSTed to /authority was refused, because +// the presence reader checks the schema inside the signed bytes and an +// authority payload declares the authority schema; +// - a presence carrier POSTed to /authority was ACCEPTED and stored as +// kind=authority, because the presence reader is exactly what it satisfies. +// +// So the inbox could hold a presence document filed as authority and could +// never hold an authority document at all. The kind in the path and the schema +// inside the attested bytes have to be checked against each other, and core +// gives one reader per kind precisely so that check is a function call rather +// than a comparison a host writes itself. +func (s *Service) receiveDeliveredDocument( + ctx context.Context, kind SolutionDeliveryKind, signed *solutionhost.Signed, +) (*receivedSolutionDelivery, error) { + switch kind { + case SolutionDeliveryPresence: + return s.receiveDeliveredPresence(ctx, signed) + case SolutionDeliveryAuthority: + return s.receiveDeliveredAuthority(ctx, signed) + } + return nil, fmt.Errorf("%w: unknown delivery kind %q", ErrSolutionDeliveryMalformed, kind) +} + +func (s *Service) receiveDeliveredPresence( + ctx context.Context, signed *solutionhost.Signed, +) (*receivedSolutionDelivery, error) { + delivered, err := solutionhost.VerifyDelivered(ctx, signed, s.deliveryVerifier) + if err != nil { + return nil, classifySolutionDeliveryVerification(err) + } + document, err := delivered.Document() + if err != nil { + // The payload verified but does not decode to a document whose canonical + // encoding is those same bytes. Malformed rather than unattested: the + // attestation is sound and the bytes are not what they claim. + return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) + } + if err := document.Validate(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryInvalid, err) + } + namespaces, err := s.presenceCarrierNamespaces(ctx, document) + if err != nil { + return nil, err + } + return &receivedSolutionDelivery{ + documentID: document.Binding, + generation: document.Generation, + ownershipDomain: document.OwnershipDomain, + signer: delivered.DeliveredBy(), + payload: delivered.Payload(), + carrierNamespaces: namespaces, + }, nil +} + +func (s *Service) receiveDeliveredAuthority( + ctx context.Context, signed *solutionhost.Signed, +) (*receivedSolutionDelivery, error) { + delivered, err := solutionhost.VerifyDeliveredAuthority(ctx, signed, s.deliveryVerifier) + if err != nil { + return nil, classifySolutionDeliveryVerification(err) + } + document, err := delivered.Document() + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) + } + if err := document.Validate(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrSolutionDeliveryInvalid, err) + } + // The authority document's identity is its OWN id, never the presence + // binding it is granted over. One binding can be granted authority under + // successive authority IDs, and core's own fold is keyed on the binding + // precisely so a rename does not reinstate a withdrawal — which only works + // if the inbox keeps the authority ID it was delivered as. + return &receivedSolutionDelivery{ + documentID: document.Authority, + generation: document.Generation, + ownershipDomain: document.OwnershipDomain, + signer: delivered.DeliveredBy(), + payload: delivered.Payload(), + // Authority has ONE writer namespace for the whole platform, so there + // is nothing read out of the document to authorise the carrier against. + carrierNamespaces: nil, + }, nil +} + +// classifySolutionDeliveryVerification separates "the signer is wrong" from +// "the bytes are wrong", because the two have different response codes and a +// caller acts differently on each. +// +// core's VerifyDelivered does two things behind one error: it hands the payload +// and bundle to this host's verifier, and it then reads the attested bytes with +// the reader for the kind. Only the first is about the SIGNER. A schema +// mismatch, a payload that is not its own canonical encoding, or a document +// core will not parse are all facts about the bytes — which is why a genuine +// authority document POSTed to /presence is 400 rather than 403. Answering 403 +// would send an operator to look at the signing identity for a delivery that +// was signed perfectly well and simply sent to the wrong path. +func classifySolutionDeliveryVerification(err error) error { + if errors.Is(err, solutionhost.ErrUnsigned) { + return fmt.Errorf("%w: %w", ErrSolutionDeliveryUnattested, err) + } + return fmt.Errorf("%w: %w", ErrSolutionDeliveryMalformed, err) +} + +// presenceCarrierNamespaces is what a presence carrier is authorised against. +// +// Normally it is the namespaces the document's OWN workloads declare, through +// their SPIFFE IDs — a property of the signed bytes rather than of host +// configuration a deployer can edit. +// +// A generation that declares no workload declares no namespace, and there are +// two of those: a TOMBSTONE, which declares absence and may carry nothing at +// all, and a present generation that renders only a frontend surface. Refusing +// the empty case outright made REMOVAL THROUGH THIS ENDPOINT IMPOSSIBLE — the +// one document delivery must be able to hand over to withdraw a binding was the +// one document the carrier check could never authorise. +// +// So the fallback is the binding's LAST APPLIED namespace: what this host +// recorded, from a generation it had already verified and admitted, for the same +// binding. That is a stronger anchor than the arriving document, because the +// arriving document is the thing under check and the applied record is the +// host's own state. +// +// It can still come back empty — a first generation for a binding nothing has +// applied, or a binding whose applied generation itself declared no workload. +// Empty is handed to the authorizer rather than resolved here, so the refusal +// reads in one place: there is nothing to authorise this carrier against. +func (s *Service) presenceCarrierNamespaces( + ctx context.Context, document *solutionhost.SolutionHostBinding, +) ([]string, error) { + if declared := declaredNamespaces(document); len(declared) > 0 { + return declared, nil + } + records, err := s.ListSolutionHostBindings(ctx) + if err != nil { + // NOT an empty namespace set. A store this host cannot read must not + // turn into "authorise against nothing", which for a document that + // declares none is indistinguishable from a carrier check that passed. + return nil, err + } + for _, record := range records { + if record.BindingID != document.Binding || record.Applied == nil { + continue + } + applied, err := solutionhost.Parse([]byte(record.Applied.Document)) + if err != nil { + // This host wrote that document itself, after verifying and + // admitting it. Unparseable means the host's own state is damaged, + // which is reported rather than treated as "no namespace". + return nil, fmt.Errorf("%w: binding %q applied generation %d does not parse back: %w", + ErrSolutionDeliveryInvalid, record.BindingID, record.Applied.Generation, err) + } + return declaredNamespaces(applied), nil + } + return nil, nil +} + +// signerMaySpeakFor reports whether an attested signer is granted an ownership +// domain, by the same mapping core's Admit is given. +func (s *Service) signerMaySpeakFor(signer, domain string) bool { + for _, granted := range s.deliveryDomainsBySigner[signer] { + if granted == domain { + return true + } + } + return false +} + +// declaredNamespaces is the set of namespaces the document's own workloads name, +// read out of each workload identity's SPIFFE ID. +// +// A presence carrier is authorised against this rather than against a configured +// constant, because a presence Job runs one per module tree in that module's own +// namespace — a fixed value would refuse every genuine carrier. Reading it from +// the signed document also keeps it out of configuration a deployer can edit. +// +// More than one distinct namespace is returned as more than one entry, and the +// authorizer refuses that: the renderer uses one namespace per render and cannot +// emit such a document, so one that exists was hand-built, and "the namespace +// this was delivered from" would have no single answer. +func declaredNamespaces(document *solutionhost.SolutionHostBinding) []string { + seen := map[string]bool{} + var namespaces []string + for _, workload := range document.Workloads { + namespace := spiffeNamespace(workload.Identity.SPIFFEID) + if namespace == "" || seen[namespace] { + continue + } + seen[namespace] = true + namespaces = append(namespaces, namespace) + } + return namespaces +} + +// spiffeNamespace reads the namespace out of `spiffe:///ns//sa/`. +// +// It returns empty rather than guessing when the shape is not that: core +// validates the SPIFFE ID's form, so an ID reaching here in another shape is one +// core accepted and this host does not understand — and inventing a namespace +// from it would authorise a carrier against a name nobody wrote. +func spiffeNamespace(id string) string { + _, path, found := strings.Cut(id, "://") + if !found { + return "" + } + segments := strings.Split(path, "/") + for index := 0; index+1 < len(segments); index++ { + if segments[index] == "ns" { + return segments[index+1] + } + } + return "" +} + +// solutionDeliveryContentHash is the idempotency key's content half: sha256 over +// the canonical payload, in the `sha256:<64 hex>` shape the schema requires. +func solutionDeliveryContentHash(payload []byte) string { + sum := sha256.Sum256(payload) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// DeliveredSolutionHostBindings is the reconciler's source, reading the newest +// generation per binding from the durable inbox. +// +// It replaces the mount, and it closes the gap a mount could not: a document +// whose apply failed stays in the desired set and is retried on the next pass, +// because the desired set is what the host was TOLD rather than what a directory +// still happens to contain. +type DeliveredSolutionHostBindings struct { + service *Service +} + +// NewDeliveredSolutionHostBindings builds the durable source. +func NewDeliveredSolutionHostBindings(service *Service) *DeliveredSolutionHostBindings { + return &DeliveredSolutionHostBindings{service: service} +} + +// Documents returns the newest delivered generation per binding, as carriers. +// +// Each row is handed back as the carrier it arrived as, so the reconciler +// re-verifies it on every pass rather than trusting the inbox. That is +// deliberate and it is what makes a narrowed verification policy take effect: a +// signer removed from the allowlist stops being able to keep its documents +// applied, instead of its last delivery remaining authoritative forever. +// +// An unreadable inbox is an ERROR, not an empty desired set — the same rule the +// mount had, for the same reason: removal is a tombstone generation precisely so +// that losing the input can never be reconciled as "remove everything". +func (d *DeliveredSolutionHostBindings) Documents(ctx context.Context) ([]SolutionHostBindingDocument, error) { + var records []*SolutionDeliveryRecord + if err := d.service.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + records, err = d.service.deliveryStore.ListNewestDeliveredDocuments(ctx, SolutionDeliveryPresence) + return err + }); err != nil { + return nil, fmt.Errorf("read the delivery inbox: %w", err) + } + documents := make([]SolutionHostBindingDocument, 0, len(records)) + for _, record := range records { + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: record.Payload, + Bundle: record.Bundle, + }) + if err != nil { + // A row that cannot be put back into a carrier is a defect in what + // was stored, and it must not read as "delivery withdrew this". + return nil, fmt.Errorf("rebuild the carrier for %s generation %d: %w", + record.DocumentID, record.Generation, err) + } + documents = append(documents, SolutionHostBindingDocument{ + Source: fmt.Sprintf("inbox:%s@%d", record.DocumentID, record.Generation), + Data: carrier, + }) + } + return documents, nil +} diff --git a/module/services/accounts/code/pkg/business/solution_delivery_activation.go b/module/services/accounts/code/pkg/business/solution_delivery_activation.go new file mode 100644 index 000000000..be923c519 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_delivery_activation.go @@ -0,0 +1,411 @@ +package business + +import ( + "context" + "errors" + "fmt" + + "github.com/codefly-dev/core/solutionhost" +) + +// Activation: the one shape in which delivered authority is active (issue #952). +// +// WHAT THIS CLOSES. The delivery endpoint accepted authority documents and +// persisted them, and nothing ever read one. There was no call site for core's +// Activate anywhere in this host, so the authority half of the lifecycle was a +// write-only inbox: an operator could deliver a reviewed, signed, envelope-bound +// grant and the host would store it and behave exactly as if it had not. +// +// NEITHER HALF ACTIVATES ALONE, which is why this takes a binding and a build +// rather than offering a way to ask the question of a document. An authority +// document on its own grants nothing, because nothing says the build it approves +// is the build that is present; a presence document on its own grants nothing, +// because nothing says anyone was granted authority over it. The build is a +// parameter rather than something read out of either document, because it is the +// thing being asked about — reading it out of the document that approves it would +// make the question answer itself. +// +// THE CEILING IS NOT HERE. core's Envelope is supplied by the caller and never +// read out of a document, because "an envelope a document carried would be a +// document declaring its own ceiling" — and ValidateAgainst tests a document's +// grants and approved build against the envelope's, so an envelope assembled +// from the delivery tree answers itself. The host reads it from the trust anchor, +// a platform-owned path neither delivery writer can write; see +// infra.ReadSolutionAuthorityEnvelope. +// +// THIS HOST APPLIES AUTHORITY TO NOTHING, and that is stated rather than +// defaulted. core's ActivationRequest requires either the applied authority +// record or an explicit marker that none exists, because "nothing applied" is +// the most permissive input the call takes. This host holds no applied authority +// record — there is no durable AppliedAuthority state and nothing to reconcile +// it into — so the marker is the truth, and the replay protection core's fold +// would otherwise give comes from the inbox instead: the desired set is the +// NEWEST generation per document, so a genuinely signed older generation +// re-delivered never becomes the answer. +// +// One thing the fold does that the inbox does not do by itself is refuse an +// authority WITHDRAWN under one id and re-signed under another — core keys that +// on the binding precisely so a rename cannot reinstate it. So this does it +// explicitly: every delivered authority document over the binding is read, and a +// tombstone among them refuses activation for that binding permanently. Nothing +// deletes from the inbox, so a withdrawal stays visible forever, which is what +// makes that check equivalent rather than merely similar. + +var ( + // ErrSolutionAuthorityCeilingUnavailable reports that no authority envelope + // is delivered to this host, so it can answer no authority question. + // + // Distinct from "not activated" on purpose: nothing was judged. A host with + // no ceiling has not decided that a grant is absent, it has decided + // nothing, and an operator fixes those two in different places. + ErrSolutionAuthorityCeilingUnavailable = errors.New("this host has no authority envelope, so it activates nothing") + + // ErrSolutionAuthorityNotDelivered reports that one half is missing from the + // inbox: no authority document over this binding, or no presence document + // for it. + ErrSolutionAuthorityNotDelivered = errors.New("solution authority or presence has not been delivered for this binding") + + // ErrSolutionAuthorityWithdrawn reports that authority over this binding was + // withdrawn. TERMINAL for the binding, including for an authority re-signed + // under a new id: a withdrawal is a statement about the binding, and + // renaming the grant does not reinstate it. + ErrSolutionAuthorityWithdrawn = errors.New("solution authority over this binding was withdrawn") + + // ErrSolutionAuthorityAmbiguous reports more than one live authority + // document over one binding. Refused rather than resolved: whichever one a + // host picked would be a choice nobody reviewed. + ErrSolutionAuthorityAmbiguous = errors.New("more than one live solution authority document is delivered over this binding") +) + +// SolutionAuthorityActivation answers whether delivered authority is active for +// a binding at a build. +// +// It is built on the reconciler rather than beside it, and that is not +// convenience: the reconciler already holds the four policy inputs activation +// needs — this host's coordinate, the ownership domains it accepts, which signer +// may speak for which domain, and the bundle verifier — and a second copy of any +// of them is a second answer to "what does this host accept". +type SolutionAuthorityActivation struct { + reconciler *SolutionHostBindingReconciler + envelope solutionhost.Envelope +} + +// newSolutionAuthorityActivation builds the activation reader, which the +// reconciler's constructor owns: a caller that could build one with a different +// coordinate, a different signer policy or a different verifier than the +// reconciler's would be a second answer to what this host accepts. Reach it +// through SolutionHostBindingReconciler.AuthorityActivation. +// +// A zero-revision envelope is accepted and makes every activation refuse with +// ErrSolutionAuthorityCeilingUnavailable, rather than refusing at construction. +// The distinction matters: a host that reconciles presence and has no ceiling +// delivered is a correct, complete deployment — authority is simply not a +// question it can answer — while a host that refused to start would make the +// presence half depend on the authority half being configured. +func newSolutionAuthorityActivation( + reconciler *SolutionHostBindingReconciler, envelope solutionhost.Envelope, +) (*SolutionAuthorityActivation, error) { + if reconciler == nil { + return nil, errors.New("solution authority activation requires the declared-presence reconciler, which holds this host's coordinate, domains, signer policy and verifier") + } + return &SolutionAuthorityActivation{reconciler: reconciler, envelope: envelope}, nil +} + +// Activate reports whether the delivered authority over binding and the +// delivered presence for it form a matched tuple for build. +// +// Both halves are RE-VERIFIED out of the inbox on every call rather than trusted +// as stored, which is the same rule the reconcile pass follows and for the same +// reason: a signer removed from the allowlist must stop being able to keep its +// documents effective, instead of its last delivery remaining authoritative +// forever. +func (a *SolutionAuthorityActivation) Activate( + ctx context.Context, binding string, build solutionhost.ImageDigest, +) (solutionhost.Activation, error) { + if a == nil || a.reconciler == nil { + return solutionhost.Activation{}, ErrSolutionAuthorityCeilingUnavailable + } + if a.envelope.Revision == 0 { + return solutionhost.Activation{}, fmt.Errorf("%w: binding %q", ErrSolutionAuthorityCeilingUnavailable, binding) + } + + authority, err := a.deliveredAuthority(ctx, binding) + if err != nil { + return solutionhost.Activation{}, err + } + presence, err := a.deliveredPresence(ctx, binding) + if err != nil { + return solutionhost.Activation{}, err + } + host := a.reconciler.host + records, err := a.appliedState(ctx) + if err != nil { + return solutionhost.Activation{}, err + } + return solutionhost.Activate(solutionhost.ActivationRequest{ + Authority: authority, + Presence: presence, + Coordinate: host.Coordinate, + Build: build, + Envelope: a.envelope, + DomainsBySigner: host.DomainsBySigner, + // The ownership domains this host ACCEPTS, which is a different + // question from which signer may speak for one. Core requires it for + // the reason it requires it of Admit: activation checked who may speak + // for a domain and never whether the host accepts the domain at all, + // so an activation succeeded for a domain the host does not list while + // Admit refused the very same documents. + Domains: host.Domains, + // The applied state, which CORE READS ITSELF through this reader. + Records: records, + }) +} + +// appliedState builds the reader core reads the host's applied state through. +// +// It pre-loads the records because the interface takes no context: a reader that +// closed over one and queried per call would run a database read inside core's +// judgement, where a failure has nowhere to go but a bool. +func (a *SolutionAuthorityActivation) appliedState( + ctx context.Context, +) (*appliedSolutionState, error) { + records, err := a.reconciler.service.ListSolutionHostBindings(ctx) + if err != nil { + return nil, fmt.Errorf("read applied solution host bindings: %w", err) + } + byBinding := make(map[string]*SolutionHostBindingRecord, len(records)) + for _, record := range records { + byBinding[record.BindingID] = record + } + return &appliedSolutionState{byBinding: byBinding}, nil +} + +// appliedSolutionState answers core's two applied-state questions. +// +// WHY THIS EXISTS AT ALL, since an earlier version of this file said it did not. +// That comment read core v0.9.0's removal of `Applied`, `FirstAuthorityRecord`, +// `AppliedPresence` and `FirstPresenceRecord` and concluded activation no longer +// takes the host's applied state. It does: core replaced four caller-SUPPLIED +// fields with one reader it calls ITSELF, having derived the binding from the +// attested presence bytes — so the key cannot be the caller's choice and "no +// record" cannot be the caller's assertion. Passing neither it nor Domains made +// core refuse every tuple by name, which is how this was found. +type appliedSolutionState struct { + byBinding map[string]*SolutionHostBindingRecord +} + +// PresenceRecord answers from the durable applied generation. +func (s *appliedSolutionState) PresenceRecord(binding string) (solutionhost.Applied, bool, error) { + record, ok := s.byBinding[binding] + if !ok { + return solutionhost.Applied{}, false, nil + } + applied, ok := record.AppliedState() + return applied, ok, nil +} + +// AuthorityRecord reports that there is none, for every binding. +// +// THIS HOST HOLDS NO APPLIED AUTHORITY STATE. There is no durable +// AppliedAuthority relation and nothing to reconcile one into, so "no record" is +// the truth here rather than a marker — and it is the truth UNIFORMLY, which is +// what makes it safe. Core's reason for taking the reader was that a host keyed +// by AUTHORITY ID truthfully found no record for a RENAMED authority and +// activated it over a withdrawn binding: the bypass needed a lookup that could +// miss. A reader that answers "none" for every binding cannot miss selectively. +// +// The replay protection core's fold would otherwise give comes from the inbox +// instead, and that is not a weaker substitute for the renaming case +// specifically: deliveredAuthority reads EVERY delivered authority document over +// the binding and a tombstone among them refuses activation permanently, +// whatever id it was signed under. Nothing deletes from the inbox, so a +// withdrawal stays visible forever. +// +// If this host ever grows durable applied authority state, this must answer from +// it, keyed on the BINDING and never on the authority id. +func (s *appliedSolutionState) AuthorityRecord(string) (solutionhost.AppliedAuthority, bool, error) { + return solutionhost.AppliedAuthority{}, false, nil +} + +var _ solutionhost.AppliedStateReader = (*appliedSolutionState)(nil) + +// Core v0.9.0 replaced `Applied`, `FirstAuthorityRecord`, `AppliedPresence` and +// `FirstPresenceRecord` with ONE `Records AppliedStateReader` that core calls +// itself, keyed on a binding it derives from the attested presence bytes. An +// earlier version of this comment read the removal and missed the replacement, +// concluding activation no longer takes applied state; it does, and passing +// neither it nor `Domains` made core refuse every tuple. +// +// None of that retires the withdrawal check below. Core's activation judges the +// documents it is handed and the records it reads; it does not know that a +// tombstone for this binding was delivered under a different authority id, +// because nothing in the request carries the inbox — and this host's +// AuthorityRecord has none to give. Reading every authority document over the +// binding is still this host's job. +// +// deliveredAuthority reads the live authority document over one binding. +// +// EVERY delivered authority document is read, not just the one that matches. +// Two reasons, and the first is the hole: a withdrawal is recorded under the +// authority id it was delivered as, so finding "the authority over this binding" +// by looking only at a candidate would miss a tombstone under a different id — +// which is exactly the rename that core's binding-keyed fold exists to refuse. +// The second is that two live documents over one binding have no right answer, +// and a host that read the first match would pick one silently. +func (a *SolutionAuthorityActivation) deliveredAuthority( + ctx context.Context, binding string, +) (*solutionhost.DeliveredAuthority, error) { + delivered, err := a.verifiedAuthority(ctx) + if err != nil { + return nil, err + } + var live []*solutionhost.DeliveredAuthority + for _, one := range delivered { + if one.document.PresenceBinding != binding { + continue + } + if one.document.Removed { + return nil, fmt.Errorf("%w: authority %q was withdrawn at generation %d, and a withdrawal is terminal for binding %q — re-signing under a new authority id does not reinstate it", + ErrSolutionAuthorityWithdrawn, one.document.Authority, one.document.Generation, binding) + } + live = append(live, one.delivered) + } + switch len(live) { + case 0: + return nil, fmt.Errorf("%w: no authority document is delivered over binding %q", + ErrSolutionAuthorityNotDelivered, binding) + case 1: + return live[0], nil + } + return nil, fmt.Errorf("%w: %d over binding %q", ErrSolutionAuthorityAmbiguous, len(live), binding) +} + +// verifiedAuthorityDocument pairs a re-verified carrier with the document it +// carries, so a caller that needs both does not verify twice — and, more to the +// point, so there is exactly one place where a stored row is turned back into a +// document this host accepts. +type verifiedAuthorityDocument struct { + delivered *solutionhost.DeliveredAuthority + document *solutionhost.AuthorityDocument +} + +// verifiedAuthority re-verifies EVERY newest-generation authority row and +// returns what it read, tombstones included. +// +// FAIL CLOSED OVER THE WHOLE READ rather than skipping the row. The binding a +// row is over is only knowable by reading it, so a row this host can no longer +// verify leaves the authority state for EVERY binding unestablished — including +// whether a withdrawal is among them. Skipping it would make an unverifiable +// tombstone disappear, which is the one row whose disappearance grants +// something. +// +// Tombstones are RETURNED rather than filtered here, because the two callers +// need opposite things from them and neither reading is the general one: +// activation treats a tombstone over its binding as terminal, while the +// approved-build view treats it as the removal of a principal's entry. A helper +// that dropped them would silently give the second caller the first caller's +// answer. +func (a *SolutionAuthorityActivation) verifiedAuthority( + ctx context.Context, +) ([]verifiedAuthorityDocument, error) { + records, err := a.newestDelivered(ctx, SolutionDeliveryAuthority) + if err != nil { + return nil, err + } + read := make([]verifiedAuthorityDocument, 0, len(records)) + for _, record := range records { + delivered, err := solutionhost.VerifyDeliveredAuthority(ctx, carrierOf(record), a.reconciler.verifier) + if err != nil { + return nil, fmt.Errorf("re-verify delivered authority %s generation %d: %w", + record.DocumentID, record.Generation, err) + } + document, err := delivered.Document() + if err != nil { + return nil, fmt.Errorf("read delivered authority %s generation %d: %w", + record.DocumentID, record.Generation, err) + } + read = append(read, verifiedAuthorityDocument{delivered: delivered, document: document}) + } + return read, nil +} + +// liveAuthorityDocuments returns the authority documents that are in force: +// every re-verified document that is not a tombstone. +// +// A tombstone is DROPPED rather than reported, and that is the removal the +// approved-build view needs — a withdrawn authority leaves its principals with +// no entry, so they go back to being unknown and are refused. Reporting it as +// an error instead would make one withdrawal stop the whole view from being +// rebuilt, which keeps the withdrawn build serving: the opposite of what the +// withdrawal asked for. +func (a *SolutionAuthorityActivation) liveAuthorityDocuments( + ctx context.Context, +) ([]*solutionhost.AuthorityDocument, error) { + read, err := a.verifiedAuthority(ctx) + if err != nil { + return nil, err + } + live := make([]*solutionhost.AuthorityDocument, 0, len(read)) + for _, one := range read { + if one.document.Removed { + continue + } + live = append(live, one.document) + } + return live, nil +} + +// deliveredPresence reads the newest delivered presence document for one +// binding. The inbox is keyed by the binding for this kind, so this is a lookup +// rather than a scan. +func (a *SolutionAuthorityActivation) deliveredPresence( + ctx context.Context, binding string, +) (*solutionhost.Delivered, error) { + records, err := a.newestDelivered(ctx, SolutionDeliveryPresence) + if err != nil { + return nil, err + } + for _, record := range records { + if record.DocumentID != binding { + continue + } + delivered, err := solutionhost.VerifyDelivered(ctx, carrierOf(record), a.reconciler.verifier) + if err != nil { + return nil, fmt.Errorf("re-verify delivered presence %s generation %d: %w", + record.DocumentID, record.Generation, err) + } + return delivered, nil + } + return nil, fmt.Errorf("%w: no presence document is delivered for binding %q", + ErrSolutionAuthorityNotDelivered, binding) +} + +func (a *SolutionAuthorityActivation) newestDelivered( + ctx context.Context, kind SolutionDeliveryKind, +) ([]*SolutionDeliveryRecord, error) { + service := a.reconciler.service + if service.deliveryStore == nil { + return nil, fmt.Errorf("%w: this host has no delivery inbox wired", ErrSolutionAuthorityCeilingUnavailable) + } + var records []*SolutionDeliveryRecord + if err := service.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + records, err = service.deliveryStore.ListNewestDeliveredDocuments(ctx, kind) + return err + }); err != nil { + return nil, fmt.Errorf("read the %s delivery inbox: %w", kind, err) + } + return records, nil +} + +// carrierOf rebuilds the carrier a row arrived as, so the stored bytes go back +// through core's verification rather than being trusted because they are stored. +// core validates the carrier's own invariants, so a row that cannot be a carrier +// is refused by the same check that refuses one off the wire. +func carrierOf(record *SolutionDeliveryRecord) *solutionhost.Signed { + return &solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: record.Payload, + Bundle: record.Bundle, + } +} diff --git a/module/services/accounts/code/pkg/business/solution_entitlements.go b/module/services/accounts/code/pkg/business/solution_entitlements.go new file mode 100644 index 000000000..847c5e366 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_entitlements.go @@ -0,0 +1,73 @@ +package business + +import ( + "context" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/wool" +) + +// SolutionEntitlementStore is the narrow persistence surface behind the +// per-viewer solution projection (issue #949), mirroring the InstallationStore +// pattern so business depends on an interface rather than the full PostgresStore. +// Its one method runs inside the caller's WithOrgTx transaction. +type SolutionEntitlementStore interface { + // ListSolutionEntitlements returns the org's active installations whose + // authority-root scope node subjectID may act on, ordered on the keyset the + // cursor advances. limit is the wanted page size PLUS one: the extra row is how + // the caller detects a further page, as in ListAccessibleScopes. + ListSolutionEntitlements(ctx context.Context, orgID, subjectID string, pageToken string, limit int) ([]*gen.SolutionEntitlement, error) +} + +func (s *Service) solutionEntitlementStore() SolutionEntitlementStore { + if es, ok := s.store.(SolutionEntitlementStore); ok { + return es + } + panic("Service.store does not implement SolutionEntitlementStore; see postgres_solution_entitlements.go") +} + +// listSolutionEntitlementsDefaultPageSize / …MaxPageSize bound the listing; the +// max mirrors the proto ceiling. The default is the same as the installation +// listing's, because this is that listing narrowed by authority and an +// organization's installed set is what both page over. +const ( + listSolutionEntitlementsDefaultPageSize = 200 + listSolutionEntitlementsMaxPageSize = 500 +) + +// ListSolutionEntitlements enumerates the installed solutions a subject may use. +// Always org-scoped, so it runs under WithOrgTx and RLS confines it to that +// tenant; the store resolves the installation set and the same grant + share +// union CheckAccess resolves in one statement, so the projection can never offer +// what an authority check would deny. +func (s *Service) ListSolutionEntitlements(ctx context.Context, req *gen.ListSolutionEntitlementsRequest) (*gen.ListSolutionEntitlementsResponse, error) { + w := wool.Get(ctx).In("ListSolutionEntitlements", + wool.Field("org_id", req.GetOrgId()), wool.Field("subject_id", req.GetSubjectId())) + pageSize := int(req.GetPageSize()) + if pageSize <= 0 { + pageSize = listSolutionEntitlementsDefaultPageSize + } + if pageSize > listSolutionEntitlementsMaxPageSize { + pageSize = listSolutionEntitlementsMaxPageSize + } + + var entitlements []*gen.SolutionEntitlement + if err := s.store.WithOrgTx(ctx, req.GetOrgId(), func(ctx context.Context) error { + out, e := s.solutionEntitlementStore().ListSolutionEntitlements( + ctx, req.GetOrgId(), req.GetSubjectId(), req.GetPageToken(), pageSize+1) + entitlements = out + return e + }); err != nil { + return nil, w.Wrapf(err, "cannot list solution entitlements") + } + + var nextToken string + if len(entitlements) > pageSize { + entitlements = entitlements[:pageSize] + // The cursor is the last RETURNED row's key, so the next page resumes at + // the row after the one the caller saw. + nextToken = entitlements[pageSize-1].GetInstallationId() + } + return &gen.ListSolutionEntitlementsResponse{Entitlements: entitlements, NextPageToken: nextToken}, nil +} diff --git a/module/services/accounts/code/pkg/business/solution_host_binding_admission.go b/module/services/accounts/code/pkg/business/solution_host_binding_admission.go new file mode 100644 index 000000000..240be13ea --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_host_binding_admission.go @@ -0,0 +1,106 @@ +package business + +import ( + "github.com/codefly-dev/core/solutionhost" +) + +// Admission of a delivered desired set (issue #952). +// +// core's solutionhost.Host.Admit is the judge and the whole judge. It answers +// one Admission per document — a decision, or the reason that document alone was +// refused — so a host reads the refusals it must expose straight off the result. +// +// This file used to narrow the set by hand: judge each document alone, then ask +// core about the survivors, then attribute the cross-document refusals it +// answered with one error. core v0.7.0 made that unnecessary. It attributes per +// document itself, and it refuses alias collisions one at a time until the set +// is stable — which is the subtlety that made the hand-rolled version wrong +// before its test caught it: refusing a document changes the question, because +// the generation it would have replaced keeps the aliases the set assumed it was +// releasing. Deleting the local version is the point. A host that re-derives its +// dependency's rules owns a second copy of them, and the copy is what drifts. +// +// core cd443989 narrowed the door further: Admit takes *solutionhost.Delivered, +// which only VerifyDelivered constructs from a signed carrier and this host's +// own BundleVerifier. So "this document was attested" is held by the compiler +// rather than by a naming convention, and there is no sequence of calls that +// reaches a judgement on bytes nobody verified. +// +// What remains here is the part that is this host's: reading core's answer into +// the shape the reconcile pass applies. + +// SolutionHostBindingDecision is one admitted document and what the host should +// do with it. +type SolutionHostBindingDecision struct { + // Delivered is the attested carrier this decision was made on. The apply + // re-admits inside its own transaction, and that re-admission is a HOST + // judgement, so it needs the verified value rather than the document read + // out of it — handing core the document again would be the second copy of + // "verified" that *Delivered exists to make impossible. + Delivered *solutionhost.Delivered + Document *solutionhost.SolutionHostBinding + Decision solutionhost.Decision +} + +// solutionHostBindingAdmission is the outcome of one pass's admission: the +// documents to reconcile, and the reason each withheld binding was withheld. +type solutionHostBindingAdmission struct { + Accepted []SolutionHostBindingDecision + Withheld map[string]string +} + +// admitSolutionHostBindings asks core about the whole desired set and sorts its +// answer into what to apply and what to expose as refused. +// +// core's error is deliberately ignored: it is non-nil whenever ANY document was +// refused, which is the right answer for a caller that applies all or nothing — a +// renderer refusing bytes it is about to write. A host applies what is sound and +// exposes the rest, so it reads the Admissions. Nothing core refused is applied, +// because an Admission with an Err carries no decision. +// +// A document core refused before it could read a binding ID is attributed to the +// empty key and reported by the caller against the document it came from; it +// cannot collide with a real binding, because core refuses "" as a binding ID. +func admitSolutionHostBindings( + host solutionhost.Host, delivered []deliveredSolutionHostBinding, +) solutionHostBindingAdmission { + result := solutionHostBindingAdmission{Withheld: map[string]string{}} + // The carriers are what core judges; the documents beside them are what + // this host reads to attribute the answer. Admit re-derives its own from + // each carrier's attested bytes, so the two cannot diverge — see + // deliveredSolutionHostBinding. + carriers := make([]*solutionhost.Delivered, len(delivered)) + documents := make([]*solutionhost.SolutionHostBinding, len(delivered)) + for index, one := range delivered { + carriers[index], documents[index] = one.Delivered, one.Document + } + admissions, err := host.Admit(carriers...) + if admissions == nil { + // core refused the call itself rather than any one document — applied + // state handed to it without a coordinate, or an applied record it + // could not read. Nothing is admitted, and the reason is attributed to + // every delivered binding, because the host cannot tell which document + // the refusal is about. + for _, document := range documents { + result.Withheld[document.Binding] = err.Error() + } + return result + } + result.Accepted = make([]SolutionHostBindingDecision, 0, len(admissions)) + for index, admission := range admissions { + if admission.Err != nil { + binding := admission.Binding + if binding == "" && index < len(documents) { + binding = documents[index].Binding + } + result.Withheld[binding] = admission.Err.Error() + continue + } + result.Accepted = append(result.Accepted, SolutionHostBindingDecision{ + Delivered: delivered[index].Delivered, + Document: documents[index], + Decision: admission.Decision, + }) + } + return result +} diff --git a/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go b/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go new file mode 100644 index 000000000..b9975b9bb --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_host_binding_admission_test.go @@ -0,0 +1,688 @@ +package business + +import ( + "bytes" + "context" + "errors" + "sort" + "strings" + "testing" + + "github.com/codefly-dev/core/composition" + "github.com/codefly-dev/core/solutionhost" +) + +// Admission is exercised against core's own shipped fixtures rather than against +// documents written here. A document invented in this repository agrees with this +// repository's reading of the spec by construction, which is exactly the +// agreement that is worth nothing: the renderer in codefly-dev/cli and this host +// have to reach the same verdict on the same bytes, and the fixtures are those +// bytes. + +// TestSolutionHostBindingFixturesReachTheirRequiredOutcome is the conformance +// test core ships the fixtures for: each one carries the outcome a conforming +// implementation must reach against solutionhost.FixtureHost, and this host must +// reach it through the same path it reconciles with. +func TestSolutionHostBindingFixturesReachTheirRequiredOutcome(t *testing.T) { + host, err := solutionhost.FixtureHost() + if err != nil { + t.Fatalf("fixture host: %v", err) + } + // Core ships presence, authority and signed-carrier fixtures. This host's + // admission path is the presence reconciler, so it is driven with the presence + // set; the authority and carrier documents are verified before admission and + // belong to the delivery endpoint's own conformance run. + all := solutionhost.Fixtures() + var fixtures []solutionhost.Fixture + for _, fixture := range all { + if fixture.Type == solutionhost.DocumentTypePresence { + fixtures = append(fixtures, fixture) + } + } + if len(fixtures) == 0 { + t.Fatalf("core shipped no presence fixtures among %d, so nothing was actually checked", len(all)) + } + for _, fixture := range fixtures { + t.Run(fixture.Name, func(t *testing.T) { + document, parseErr := solutionhost.Parse(fixture.Document) + if parseErr != nil { + if fixture.Outcome != solutionhost.OutcomeRejected { + t.Fatalf("fixture must be %s but did not parse: %v (%s)", + fixture.Outcome, parseErr, fixture.Reason) + } + return + } + admission := admitSolutionHostBindings(host, delivered(t, document)) + switch fixture.Outcome { + case solutionhost.OutcomeAccepted: + if reason, withheld := admission.Withheld[document.Binding]; withheld { + t.Fatalf("fixture must be accepted but was withheld: %s (%s)", reason, fixture.Reason) + } + if len(admission.Accepted) != 1 { + t.Fatalf("accepted %d documents, want 1", len(admission.Accepted)) + } + if got := admission.Accepted[0].Decision; got != fixture.Decision { + t.Fatalf("decision = %q, want %q (%s)", got, fixture.Decision, fixture.Reason) + } + case solutionhost.OutcomeRejected: + if len(admission.Accepted) != 0 { + t.Fatalf("fixture must be rejected but was admitted (%s)", fixture.Reason) + } + if _, withheld := admission.Withheld[document.Binding]; !withheld { + t.Fatalf("fixture was neither admitted nor attributed a reason (%s)", fixture.Reason) + } + default: + t.Fatalf("unknown required outcome %q", fixture.Outcome) + } + }) + } +} + +// The duplicate-route-alias fixture is rejected because an alias the fixture host +// already holds is claimed by a second binding. That is the one fixture whose +// verdict depends on host state rather than on the document, so it is worth +// pinning that the host reaches it for the collision reason and not by accident. +func TestSolutionHostBindingCollisionWithAnAppliedAliasNamesTheCollision(t *testing.T) { + host, err := solutionhost.FixtureHost() + if err != nil { + t.Fatalf("fixture host: %v", err) + } + document := mustFixtureDocument(t, "duplicate-route-alias") + admission := admitSolutionHostBindings(host, delivered(t, document)) + reason, withheld := admission.Withheld[document.Binding] + if !withheld { + t.Fatal("a binding claiming an alias an applied binding holds must be withheld") + } + if !strings.Contains(reason, composition.ErrCollision.Error()) { + t.Fatalf("reason %q does not name the collision", reason) + } +} + +// A stale document must not hold back a sibling binding's new generation. +// +// This is the failure mode that makes per-document isolation load-bearing rather +// than a nicety: the renderer derives a generation from the previously delivered +// document, so a render from a checkout that cannot see the prior tree emits +// generation 1, which this host correctly refuses. If that refusal held the whole +// set, one bad pipeline run would freeze every other solution on the host. +func TestSolutionHostBindingStaleDocumentDoesNotBlockASibling(t *testing.T) { + host, err := solutionhost.FixtureHost() + if err != nil { + t.Fatalf("fixture host: %v", err) + } + stale := mustFixtureDocument(t, "stale-generation") + sibling := mustFixtureDocument(t, "valid") + sibling.Binding = "pim-eu-west-1-01" + sibling.Routes[0].Alias = "pim" + + admission := admitSolutionHostBindings(host, delivered(t, stale, sibling)) + + if _, withheld := admission.Withheld[stale.Binding]; !withheld { + t.Fatal("the stale document must be withheld") + } + if reason, withheld := admission.Withheld[sibling.Binding]; withheld { + t.Fatalf("the sibling must still be admitted, was withheld: %s", reason) + } + if len(admission.Accepted) != 1 || admission.Accepted[0].Document.Binding != sibling.Binding { + t.Fatalf("accepted = %v, want only %q", bindingsOf(admission.Accepted), sibling.Binding) + } +} + +// Two documents claiming one alias: exactly one is admitted, and which one does +// not depend on the order the mount was walked in. +// +// This asserts core's rule, not a rule of this host's. An earlier version of this +// host attributed collisions itself and withheld EVERY claimant, reasoning that it +// had no rule making one of two simultaneous claims the winner. core v0.7.0 has +// one — lowest binding ID, resolved one refusal at a time until the set is stable +// — and it is the better answer: refusing both wedges two solutions where +// admitting the deterministic winner keeps one serving. The host no longer owns +// this policy, so the test pins core's. +func TestSolutionHostBindingTwoClaimantsOfOneAliasYieldOneDeterministicWinner(t *testing.T) { + host := fixtureSignerHost(solutionhost.FixtureCoordinate) + first := mustFixtureDocument(t, "valid") + first.Binding = "crm-eu-west-1-aa" + second := mustFixtureDocument(t, "valid") + second.Binding = "crm-eu-west-1-bb" + + forward := admitSolutionHostBindings(host, delivered(t, first, second)) + reverse := admitSolutionHostBindings(host, delivered(t, second, first)) + + for label, admission := range map[string]solutionHostBindingAdmission{"forward": forward, "reverse": reverse} { + if len(admission.Accepted) != 1 { + t.Fatalf("%s: accepted %v, want exactly one claimant", label, bindingsOf(admission.Accepted)) + } + if got := admission.Accepted[0].Document.Binding; got != first.Binding { + t.Fatalf("%s: admitted %q, want the lowest binding ID %q", label, got, first.Binding) + } + reason, withheld := admission.Withheld[second.Binding] + if !withheld { + t.Fatalf("%s: the losing claimant was neither admitted nor attributed a reason", label) + } + if !strings.Contains(reason, composition.ErrCollision.Error()) { + t.Fatalf("%s: reason %q does not name the collision", label, reason) + } + } +} + +// A collision must not take an unrelated binding down with it: the loser is +// refused, the winner and every unrelated binding still apply. +func TestSolutionHostBindingCollisionWithholdsOnlyTheLosingClaimant(t *testing.T) { + host := fixtureSignerHost(solutionhost.FixtureCoordinate) + first := mustFixtureDocument(t, "valid") + first.Binding = "crm-eu-west-1-aa" + second := mustFixtureDocument(t, "valid") + second.Binding = "crm-eu-west-1-bb" + unrelated := mustFixtureDocument(t, "valid") + unrelated.Binding = "pim-eu-west-1-01" + unrelated.Routes[0].Alias = "pim" + + admission := admitSolutionHostBindings(host, + delivered(t, first, second, unrelated)) + + admitted := bindingsOf(admission.Accepted) + sort.Strings(admitted) + if len(admitted) != 2 || admitted[0] != first.Binding || admitted[1] != unrelated.Binding { + t.Fatalf("accepted = %v, want the winning claimant and the unrelated binding", admitted) + } + if _, withheld := admission.Withheld[second.Binding]; !withheld { + t.Fatal("the losing claimant must be withheld") + } + if len(admission.Withheld) != 1 { + t.Fatalf("withheld = %v, want only the losing claimant", admission.Withheld) + } +} + +// One binding delivered twice in one pass: the second occurrence is refused and +// names both documents, so an operator can find the duplicate mount. The first +// still applies — core resolves it rather than freezing the binding, and it +// reports which two documents collided. +func TestSolutionHostBindingDeclaredTwiceRefusesTheSecondOccurrence(t *testing.T) { + host := fixtureSignerHost(solutionhost.FixtureCoordinate) + first := mustFixtureDocument(t, "valid") + second := mustFixtureDocument(t, "valid") + + admission := admitSolutionHostBindings(host, delivered(t, first, second)) + + if len(admission.Accepted) != 1 { + t.Fatalf("accepted %v, want exactly one of the two", bindingsOf(admission.Accepted)) + } + reason, withheld := admission.Withheld[first.Binding] + if !withheld { + t.Fatal("the duplicate must be attributed to its binding") + } + if !strings.Contains(reason, "declared twice") { + t.Fatalf("reason %q does not say the binding was delivered twice", reason) + } +} + +// A document targeting another host is refused here, not merged. A host that +// reconciled whatever it was handed would reconcile its neighbour's desired +// state. +func TestSolutionHostBindingForAnotherCoordinateIsWithheld(t *testing.T) { + // Derived from the fixtures' own coordinate rather than written out, so this + // test names no deployment of its own. + host := fixtureSignerHost(solutionhost.FixtureCoordinate + "-elsewhere") + document := mustFixtureDocument(t, "valid") + + admission := admitSolutionHostBindings(host, delivered(t, document)) + + if len(admission.Accepted) != 0 { + t.Fatalf("accepted %v, want nothing", bindingsOf(admission.Accepted)) + } + if reason := admission.Withheld[document.Binding]; !strings.Contains(reason, solutionhost.ErrWrongHost.Error()) { + t.Fatalf("reason %q does not name the wrong-host refusal", reason) + } +} + +// A generation whose contents changed under an applied generation number is the +// one thing core treats as an error rather than an answer, and the host must +// withhold it rather than reapply it. +func TestSolutionHostBindingRewrittenGenerationIsWithheld(t *testing.T) { + host, err := solutionhost.FixtureHost() + if err != nil { + t.Fatalf("fixture host: %v", err) + } + rewritten := mustFixtureDocument(t, "valid") + rewritten.Release.Version = "1.4.1" + for index := range rewritten.Artifacts { + rewritten.Artifacts[index].Release = rewritten.Release.Identity() + } + + admission := admitSolutionHostBindings(host, delivered(t, rewritten)) + + if len(admission.Accepted) != 0 { + t.Fatalf("accepted %v, want nothing", bindingsOf(admission.Accepted)) + } + if reason := admission.Withheld[rewritten.Binding]; !strings.Contains(reason, solutionhost.ErrRewrittenGeneration.Error()) { + t.Fatalf("reason %q does not name the rewritten generation", reason) + } +} + +// An alias being handed over must not refuse its claimant. +// +// This is the case that makes per-document judging subtle: the binding holding the +// alias is IN the delivered set, as a tombstone releasing it, so its applied claim +// must not count against the claimant. Judging each document against every applied +// alias refused the claimant here, and the hand-over then never completed — the +// next pass read the same two documents and refused the same one, forever. +func TestSolutionHostBindingAliasHandedOverInOneSetAdmitsBoth(t *testing.T) { + holder := mustFixtureDocument(t, "valid") + applied, err := solutionhost.AppliedFrom(holder) + if err != nil { + t.Fatalf("applied from the holder: %v", err) + } + host := fixtureSignerHost(solutionhost.FixtureCoordinate) + host.Applied = []solutionhost.Applied{applied} + releasing := mustFixtureDocument(t, "tombstone") + claiming := mustFixtureDocument(t, "valid") + claiming.Binding = "crm-eu-west-1-02" + + admission := admitSolutionHostBindings(host, + delivered(t, releasing, claiming)) + + if len(admission.Withheld) != 0 { + t.Fatalf("withheld %v, want a clean hand-over", admission.Withheld) + } + if len(admission.Accepted) != 2 { + t.Fatalf("accepted = %v, want both sides of the hand-over", bindingsOf(admission.Accepted)) + } +} + +// The other side of that rule: a candidate claiming an alias held by a binding +// whose document is NOT in the set is refused, and refused alone. +func TestSolutionHostBindingClaimingAHeldAliasWithholdsOnlyTheClaimant(t *testing.T) { + holder := mustFixtureDocument(t, "valid") + applied, err := solutionhost.AppliedFrom(holder) + if err != nil { + t.Fatalf("applied from the holder: %v", err) + } + host := fixtureSignerHost(solutionhost.FixtureCoordinate) + host.Applied = []solutionhost.Applied{applied} + claiming := mustFixtureDocument(t, "valid") + claiming.Binding = "crm-eu-west-1-02" + unrelated := mustFixtureDocument(t, "valid") + unrelated.Binding = "pim-eu-west-1-01" + unrelated.Routes[0].Alias = "pim" + + admission := admitSolutionHostBindings(host, + delivered(t, claiming, unrelated)) + + if _, withheld := admission.Withheld[claiming.Binding]; !withheld { + t.Fatal("a candidate claiming a held alias must be withheld") + } + if len(admission.Accepted) != 1 || admission.Accepted[0].Document.Binding != unrelated.Binding { + t.Fatalf("accepted = %v, want only the unrelated binding", bindingsOf(admission.Accepted)) + } +} + +// A binding whose alias hands over to another binding is applied before the +// claimant, so the hand-over completes in one pass instead of waiting for the +// next one to find the key free. +func TestSolutionHostBindingAppliesReleasesBeforeClaims(t *testing.T) { + releasing := mustFixtureDocument(t, "tombstone") + claiming := mustFixtureDocument(t, "valid") + claiming.Binding = "crm-eu-west-1-02" + + records := []*SolutionHostBindingRecord{ + { + BindingID: releasing.Binding, + Applied: &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4}, + Routes: []string{"crm"}, + SolutionID: "crm", + }, + }, + } + // Deliberately given claimant-first, which is the order a mount walk would + // produce for these two names. + ordered := orderSolutionHostBindingApplies([]SolutionHostBindingDecision{ + {Document: claiming, Decision: solutionhost.DecisionApply}, + {Document: releasing, Decision: solutionhost.DecisionApply}, + }, records) + + if len(ordered) != 2 || ordered[0].Document.Binding != releasing.Binding { + t.Fatalf("apply order = %v, want the releasing binding first", bindingsOf(ordered)) + } +} + +// The registry key is the route alias, and an alias this host cannot address is +// refused with a reason naming the route rather than surfacing as a database +// error later. +func TestSolutionHostBindingRegistryKeyResolution(t *testing.T) { + present := mustFixtureDocument(t, "valid") + key, err := solutionHostBindingRegistryKey(present) + if err != nil { + t.Fatalf("valid fixture: %v", err) + } + // Read from the fixture rather than hardcoded: core moved this alias once + // already, and pinning its content here tests core's testdata rather than + // this host's resolution. + if key != present.Routes[0].Alias { + t.Fatalf("registry key = %q, want the route alias %q", key, present.Routes[0].Alias) + } + // The fixture's route alias and its ownership domain are currently the SAME + // string, so the assertion above cannot tell "the alias" from "the domain" + // — and the domain is the plausible wrong answer. Re-asking with a distinct + // alias is what actually pins which field is the registry key. + distinct := mustFixtureDocument(t, "valid") + distinct.Routes[0].Alias = "storefront" + if key, err = solutionHostBindingRegistryKey(distinct); err != nil { + t.Fatalf("distinct alias: %v", err) + } + if key != "storefront" { + t.Fatalf("registry key = %q, want the route alias %q and not the ownership domain %q", + key, "storefront", distinct.OwnershipDomain) + } + + dotted := mustFixtureDocument(t, "valid") + dotted.Routes[0].Alias = "acme.crm" + if _, err := solutionHostBindingRegistryKey(dotted); !errors.Is(err, ErrSolutionHostBindingRouteNotAddressable) { + t.Fatalf("dotted alias error = %v, want ErrSolutionHostBindingRouteNotAddressable", err) + } + + routeless := mustFixtureDocument(t, "valid") + routeless.Routes = nil + if _, err := solutionHostBindingRegistryKey(routeless); !errors.Is(err, ErrSolutionHostBindingRouteRequired) { + t.Fatalf("routeless error = %v, want ErrSolutionHostBindingRouteRequired", err) + } + + twoRoutes := mustFixtureDocument(t, "valid") + twoRoutes.Routes = append(twoRoutes.Routes, solutionhost.Route{Alias: "crm-api", Surface: solutionhost.SurfaceBackend}) + if _, err := solutionHostBindingRegistryKey(twoRoutes); !errors.Is(err, ErrSolutionHostBindingRouteRequired) { + t.Fatalf("two-route error = %v, want ErrSolutionHostBindingRouteRequired", err) + } +} + +// A verified payload core refuses to parse still names the binding its refusal +// belongs to, so an operator reads "delivery is shipping something unreadable" +// beside the generation that is still running. +func TestSolutionHostBindingAttributionOfAnUnparseableDocument(t *testing.T) { + _, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(), + []SolutionHostBindingDocument{{ + Source: "mount/broken.codefly.yaml", + Data: carrierOver(t, []byte(`{"schema":"codefly/solution-host-binding/v1",`+ + `"binding":"crm-eu-west-1-01","generation":4,"whatIsThis":true}`)), + }}) + if len(problems) != 1 { + t.Fatalf("problems = %d, want 1", len(problems)) + } + if problems[0].binding != "crm-eu-west-1-01" { + t.Fatalf("attributed to %q, want the binding the document names", problems[0].binding) + } + if !strings.Contains(problems[0].err.Error(), "mount/broken.codefly.yaml") { + t.Fatalf("problem %q does not name the document it came from", problems[0].err) + } +} + +// A payload with no readable binding field is reported without a binding rather +// than attributed to a guess. +func TestSolutionHostBindingAttributionOfUnreadableBytes(t *testing.T) { + _, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(), + []SolutionHostBindingDocument{{ + Source: "mount/garbage.codefly.yaml", + // A JSON value that is not a document at all. The carrier itself is + // JSON, so bytes that are not even a JSON value are refused one + // layer earlier, by ParseSigned — which is what + // TestSolutionHostBindingRefusesAnUnsignedDocument covers. + Data: carrierOver(t, []byte(`"not a document"`)), + }}) + if len(problems) != 1 { + t.Fatalf("problems = %d, want 1", len(problems)) + } + if problems[0].binding != "" { + t.Fatalf("attributed to %q, want no attribution", problems[0].binding) + } +} + +// The regression that matters most on this change: a BARE document — the shape +// delivery shipped before signing existed, and the shape every fixture file on +// disk still has — is refused rather than admitted. There is no path from +// unattested bytes to a host judgement, and this is the test that says so. +func TestSolutionHostBindingRefusesAnUnsignedDocument(t *testing.T) { + bare, err := solutionhost.Marshal(mustFixtureDocument(t, "valid")) + if err != nil { + t.Fatalf("marshal: %v", err) + } + verified, problems := verifySolutionHostBindingDocuments(context.Background(), fixtureSigner(), + []SolutionHostBindingDocument{{Source: "mount/unsigned.codefly.yaml", Data: bare}}) + if len(verified) != 0 { + t.Fatalf("verified %d unsigned documents, want none admitted without an attestation", len(verified)) + } + if len(problems) != 1 { + t.Fatalf("problems = %d, want 1", len(problems)) + } + // A bare document is not a carrier, so there is nothing whose binding could + // be trusted enough to attribute: core refuses it before any field is read. + if problems[0].binding != "" { + t.Fatalf("attributed to %q, want no attribution for bytes that are not a carrier", problems[0].binding) + } + if !errors.Is(problems[0].err, solutionhost.ErrUnsigned) { + t.Fatalf("error = %v, want ErrUnsigned", problems[0].err) + } +} + +// A carrier whose bundle does not verify is refused, and the refusal is still +// attributed so an operator sees which binding delivery is failing for. +func TestSolutionHostBindingRefusesACarrierWhoseBundleDoesNotVerify(t *testing.T) { + document := mustFixtureDocument(t, "valid") + refusing := &stubBundleVerifier{err: errors.New("no identity in the allowlist matched")} + verified, problems := verifySolutionHostBindingDocuments(context.Background(), refusing, + []SolutionHostBindingDocument{{ + Source: "mount/forged.codefly.yaml", + Data: signedCarrier(t, document), + }}) + if len(verified) != 0 { + t.Fatalf("verified %d documents, want none", len(verified)) + } + if len(problems) != 1 { + t.Fatalf("problems = %d, want 1", len(problems)) + } + if problems[0].binding != document.Binding { + t.Fatalf("attributed to %q, want %q", problems[0].binding, document.Binding) + } +} + +// The verifier must be asked about the carrier's bytes VERBATIM. Re-encoding a +// parsed document would produce bytes the signature does not cover, and then the +// only honest answer is that nothing verified. +func TestSolutionHostBindingVerifierSeesTheCarrierBytesVerbatim(t *testing.T) { + document := mustFixtureDocument(t, "valid") + canonical, err := document.CanonicalBytes() + if err != nil { + t.Fatalf("canonical bytes: %v", err) + } + verifier := fixtureSigner() + if _, problems := verifySolutionHostBindingDocuments(context.Background(), verifier, + []SolutionHostBindingDocument{{Source: "mount/valid.codefly.yaml", Data: carrierOver(t, canonical)}}, + ); len(problems) != 0 { + t.Fatalf("problems = %v, want none", problems) + } + if len(verifier.payloads) != 1 { + t.Fatalf("verifier asked %d times, want 1", len(verifier.payloads)) + } + if !bytes.Equal(verifier.payloads[0], canonical) { + t.Fatal("the verifier was asked about bytes other than the carrier's document") + } +} + +// A signer the host accepts at all must still not be able to deliver under a +// domain the host did not let it speak for. This is the self-asserted ownership +// domain hole: the document states its own domain, so the signer policy is the +// only thing standing between an accepted signer and every accepted domain. +func TestSolutionHostBindingRefusesADomainTheSignerMayNotSpeakFor(t *testing.T) { + host, err := solutionhost.FixtureHost() + if err != nil { + t.Fatalf("fixture host: %v", err) + } + document := mustFixtureDocument(t, "valid") + // An identity the host's policy says nothing about, attesting a document + // whose domain the host does accept. + stranger := &stubBundleVerifier{signer: "https://signer.example/other-workflow@refs/heads/main"} + admission := admitSolutionHostBindings(host, deliveredBy(t, stranger, document)) + if len(admission.Accepted) != 0 { + t.Fatalf("accepted %v, want nothing from a signer with no domain policy", bindingsOf(admission.Accepted)) + } + reason, withheld := admission.Withheld[document.Binding] + if !withheld { + t.Fatal("a document delivered by a signer the host does not let speak for its domain must be withheld") + } + if !strings.Contains(reason, "speak for it") { + t.Fatalf("reason %q does not say the signer may not speak for the domain", reason) + } +} + +// PendingGeneration is what an operator reads beside the reason: it is zero when +// desired and applied agree and the desired generation otherwise. +func TestSolutionHostBindingPendingGeneration(t *testing.T) { + agreed := &SolutionHostBindingRecord{ + Desired: &SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, + Applied: &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, + }, + } + if got := agreed.PendingGeneration(); got != 0 { + t.Fatalf("pending generation = %d, want 0 when desired and applied agree", got) + } + behind := &SolutionHostBindingRecord{ + Desired: &SolutionHostBindingGeneration{Generation: 3, Digest: "sha256:bb"}, + Applied: &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, + }, + } + if got := behind.PendingGeneration(); got != 3 { + t.Fatalf("pending generation = %d, want the generation delivery is showing", got) + } + // The same generation number with different contents is pending, not current: + // that is the rewrite core refuses, and reporting it as current would hide it. + rewritten := &SolutionHostBindingRecord{ + Desired: &SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:bb"}, + Applied: &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: SolutionHostBindingGeneration{Generation: 4, Digest: "sha256:aa"}, + }, + } + if got := rewritten.PendingGeneration(); got != 4 { + t.Fatalf("pending generation = %d, want the rewritten generation", got) + } +} + +func mustFixtureDocument(t *testing.T, name string) *solutionhost.SolutionHostBinding { + t.Helper() + data, err := solutionhost.FixtureDocument(solutionhost.DocumentTypePresence, name) + if err != nil { + t.Fatalf("fixture %q: %v", name, err) + } + document, err := solutionhost.Parse(data) + if err != nil { + t.Fatalf("parse fixture %q: %v", name, err) + } + return document +} + +func bindingsOf(decisions []SolutionHostBindingDecision) []string { + names := make([]string, 0, len(decisions)) + for _, decision := range decisions { + names = append(names, decision.Document.Binding) + } + return names +} + +// Admission must read the generation the attestation covered, not the one a +// caller holding the document happens to have moved. +// +// This pins a property of the DEPENDENCY rather than of local logic, which is +// why it is worth a test here: core's Delivered used to hand out the parsed +// document it would itself admit, so `delivered.Document().Generation += 7` +// changed what Admit consumed, with the attestation still covering the original +// bytes. core 67ee7220 made Document() re-derive from the attested payload per +// call. This host is what depends on that — it holds a derived document for the +// whole pass to attribute refusals and record desired state with — so a future +// pin that regressed to the shared pointer must come back red here rather than +// as a generation nobody signed being written to a row. +// +// The assertion is deliberately two-sided: it proves the mutation really landed +// on the caller's copy AND that the verdict ignored it, so it cannot pass by +// the mutation having silently failed to apply. +func TestSolutionHostBindingAdmissionReadsTheAttestedBytesNotTheCallersCopy(t *testing.T) { + host, err := solutionhost.FixtureHost() + if err != nil { + t.Fatalf("fixture host: %v", err) + } + // The generation FixtureHost has already applied, so a sound reading is + // DecisionCurrent and a mutated one would be DecisionApply. + document := mustFixtureDocument(t, "valid") + pairs := delivered(t, document) + if len(pairs) != 1 { + t.Fatalf("delivered %d carriers, want 1", len(pairs)) + } + attested := pairs[0].Document.Generation + pairs[0].Document.Generation = attested + 7 + + admission := admitSolutionHostBindings(host, pairs) + + if reason, withheld := admission.Withheld[document.Binding]; withheld { + t.Fatalf("the attested generation is the applied one and must be admitted, was withheld: %s", reason) + } + if len(admission.Accepted) != 1 { + t.Fatalf("accepted %d documents, want 1", len(admission.Accepted)) + } + if got := admission.Accepted[0].Document.Generation; got != attested+7 { + t.Fatalf("the caller's copy reads generation %d, want %d: the mutation this test is about did not land, so the verdict below proves nothing", + got, attested+7) + } + if got := admission.Accepted[0].Decision; got != solutionhost.DecisionCurrent { + t.Fatalf("decision = %q, want %q: admission read generation %d from the caller's copy rather than %d from the attested bytes", + got, solutionhost.DecisionCurrent, attested+7, attested) + } +} + +// A carrier whose bundle is absent, null or not an object is refused as +// unsigned, and the host must never read the document out of it. +// +// The field is the one a renderer with no signing step yet can fill: writing +// `null` costs nothing and produces a carrier that is structurally a carrier. +// Core looks inside no bundle — it holds no trust root — so "there is one" is +// the entire check available at this layer, and it is the difference between a +// signed document and a document. +func TestSolutionHostBindingRefusesACarrierWithNoBundle(t *testing.T) { + document := mustFixtureDocument(t, "valid") + canonical, err := document.CanonicalBytes() + if err != nil { + t.Fatalf("canonical bytes: %v", err) + } + for _, one := range []struct { + name string + bundle string + }{ + {"null", `null`}, + {"absent", ``}, + {"a string rather than an object", `"not-a-bundle"`}, + {"an array rather than an object", `[]`}, + } { + t.Run(one.name, func(t *testing.T) { + // Hand-written rather than built with MarshalSigned, which refuses + // these itself: the point is what the host does with bytes that + // reached its mount, not what its own writer would produce. + field := "" + if one.bundle != "" { + field = `,"bundle":` + one.bundle + } + raw := []byte(`{"schema":"` + solutionhost.SchemaSignedV1 + + `","document":` + string(canonical) + field + `}`) + + verified, problems := verifySolutionHostBindingDocuments( + context.Background(), fixtureSigner(), + []SolutionHostBindingDocument{{Source: "unsigned.json", Data: raw}}, + ) + if len(verified) != 0 { + t.Fatalf("admitted %d carriers with a %s bundle, want none", len(verified), one.name) + } + if len(problems) != 1 { + t.Fatalf("recorded %d problems, want 1", len(problems)) + } + if !errors.Is(problems[0].err, solutionhost.ErrUnsigned) { + t.Fatalf("refusal %v is not ErrUnsigned, so the host refused it for some other reason", problems[0].err) + } + }) + } +} diff --git a/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go b/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go new file mode 100644 index 000000000..651a5bc96 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_host_binding_delivery_fake_test.go @@ -0,0 +1,135 @@ +package business + +import ( + "context" + "encoding/json" + "testing" + + "github.com/codefly-dev/core/solutionhost" +) + +// Admission takes *solutionhost.Delivered, which has unexported fields and no +// constructor but solutionhost.VerifyDelivered. That is deliberate on core's +// part — it is how "this document was attested" becomes a compiler fact rather +// than a naming convention — and it means a test cannot fabricate one. So these +// helpers build a real carrier and run it through a real VerifyDelivered with a +// stub attestation check, which is also the only way a test can exercise the +// refusal paths the host must record. + +// stubBundleVerifier is a BundleVerifier that attests whatever it is told to. +// It stands in for sigstore-go, which cannot be exercised here: a real bundle +// would need a trust root, a transparency-log entry and a certificate that +// expires, none of which belong in a unit test. +type stubBundleVerifier struct { + // signer is the identity attested for any bundle. Empty means refuse. + signer string + + // err, when set, is the refusal — a bundle that does not verify. + err error + + // payloads records the exact bytes each call was asked about, so a test can + // assert the verifier saw the carrier's document verbatim rather than a + // re-encoding of a parsed form. + payloads [][]byte +} + +func (v *stubBundleVerifier) VerifyBundle(_ context.Context, payload []byte, _ json.RawMessage) (string, error) { + v.payloads = append(v.payloads, payload) + if v.err != nil { + return "", v.err + } + return v.signer, nil +} + +// fixtureSigner attests every carrier as core's own fixture signer identity, +// which solutionhost.FixtureHost's DomainsBySigner policy is written against. +func fixtureSigner() *stubBundleVerifier { + return &stubBundleVerifier{signer: solutionhost.FixtureDeliveredBy} +} + +// signedCarrier wraps one document in the carrier delivery would ship: the +// canonical bytes verbatim, plus a bundle over them. +// +// CanonicalBytes, not Marshal. Marshal renders YAML for a delivery repository; +// the SIGNING input is the canonical JSON, and PresenceFromVerified refuses a +// payload that is not the canonical encoding of the document it decodes to — +// even when the attestation over those bytes is genuine, because a signer and a +// host that disagree about which bytes represent the document disagree about +// what was approved. +func signedCarrier(t *testing.T, document *solutionhost.SolutionHostBinding) []byte { + t.Helper() + canonical, err := document.CanonicalBytes() + if err != nil { + t.Fatalf("canonical bytes for %q: %v", document.Binding, err) + } + return carrierOver(t, canonical) +} + +// carrierOver wraps arbitrary payload bytes, so a test can deliver bytes that +// are deliberately not a valid document. The carrier is JSON, so a payload that +// is not a JSON value cannot be carried at all — which is itself the shape of +// the refusal a test about unreadable bytes is asserting. +func carrierOver(t *testing.T, payload []byte) []byte { + t.Helper() + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: payload, + Bundle: json.RawMessage(solutionhost.FixtureBundle), + }) + if err != nil { + t.Fatalf("marshal carrier: %v", err) + } + return carrier +} + +// delivered verifies documents through the real VerifyDelivered with the fixture +// signer, producing the values admission takes. +func delivered(t *testing.T, documents ...*solutionhost.SolutionHostBinding) []deliveredSolutionHostBinding { + t.Helper() + return deliveredBy(t, fixtureSigner(), documents...) +} + +// deliveredBy is delivered with a named attestation check, for a test about who +// signed rather than about what was signed. +// +// It pairs each carrier with the document re-derived from its attested bytes, +// the way the reconcile pass does, rather than with the document it was handed: +// a helper that returned the caller's own pointer would let a test assert +// against a value admission never reads. See deliveredSolutionHostBinding. +func deliveredBy( + t *testing.T, verifier solutionhost.BundleVerifier, documents ...*solutionhost.SolutionHostBinding, +) []deliveredSolutionHostBinding { + t.Helper() + out := make([]deliveredSolutionHostBinding, 0, len(documents)) + for _, document := range documents { + carrier, err := solutionhost.ParseSigned(signedCarrier(t, document)) + if err != nil { + t.Fatalf("parse carrier for %q: %v", document.Binding, err) + } + one, err := solutionhost.VerifyDelivered(context.Background(), carrier, verifier) + if err != nil { + t.Fatalf("verify carrier for %q: %v", document.Binding, err) + } + attested, err := one.Document() + if err != nil { + t.Fatalf("re-derive document for %q: %v", document.Binding, err) + } + out = append(out, deliveredSolutionHostBinding{Delivered: one, Document: attested}) + } + return out +} + +// fixtureSignerHost is a host at one coordinate that accepts core's fixture +// domain and lets the fixture signer speak for it. +// +// DomainsBySigner is required whenever a coordinate is set — core refuses the +// call without it — so a test host that omitted it would fail with "declare a +// signer policy" instead of the refusal it meant to assert, which is how a test +// passes for the wrong reason. +func fixtureSignerHost(coordinate string) solutionhost.Host { + return solutionhost.Host{ + Coordinate: coordinate, + Domains: []string{solutionhost.FixtureDomain}, + DomainsBySigner: map[string][]string{solutionhost.FixtureDeliveredBy: {solutionhost.FixtureDomain}}, + } +} diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings.go b/module/services/accounts/code/pkg/business/solution_host_bindings.go new file mode 100644 index 000000000..5588e4e18 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_host_bindings.go @@ -0,0 +1,701 @@ +package business + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "sync" + "time" + + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/wool" + "gopkg.in/yaml.v3" +) + +// Declared solution presence (issue #952). +// +// Delivery declares which solution runs on this host at which generation. Core +// owns the document and its admission rules; the host reconciles admitted +// declarations into the durable registry. +// +// DESIRED records the newest generation delivery supplied and any refusal. +// APPLIED records the generation reconciled into durable state. Endpoint and +// manifest observations remain separate from that declaration and never grant +// presence or installation authority. + +const ( + // SolutionHostBindingReconcileInterval is how often the host re-reads its + // mount. Delivery replaces a mounted document at its own cadence and gives + // the host no signal, so the pass is a poll; core's DecisionCurrent exists + // precisely because the same generation is read over and over. + SolutionHostBindingReconcileInterval = 30 * time.Second + + // SolutionHostReservedRouteNamespace is the route namespace no delivered + // binding may claim. Passing it to core reserves "codefly", "codefly/admin" + // and "codefly.admin" — the host's own surfaces. + SolutionHostReservedRouteNamespace = "codefly" +) + +var ( + // ErrSolutionHostBindingNotDeclared is returned when a binding ID has no + // durable record. + ErrSolutionHostBindingNotDeclared = errors.New("solution host binding is not declared") + // ErrSolutionHostBindingRouteRequired is returned when a present generation + // does not declare exactly one route alias. core permits several; this host + // reconciles a binding into one registry record addressed by one path + // segment, so it needs exactly one and refuses rather than choosing. + ErrSolutionHostBindingRouteRequired = errors.New("solution host binding must declare exactly one route alias") + // ErrSolutionHostBindingRouteNotAddressable is returned when the declared + // alias is not a registry solution id: core's alias vocabulary admits dots + // and slashes, and a registry key is one lowercase path segment. + ErrSolutionHostBindingRouteNotAddressable = errors.New("solution host binding route alias is not an addressable solution id") + // ErrSolutionHostBindingKindNotRoutable is returned when a present generation + // declares a kind this host has no routing surface for. core admits only + // "solution" and "module", so this is reachable only from a core whose + // vocabulary grew past this host's — and the answer is a refusal that leaves + // the running generation alone, not a record no surface serves. + ErrSolutionHostBindingKindNotRoutable = errors.New("solution host binding declares a kind this host does not route") + // ErrSolutionHostBindingRouteHeld is returned when the registry record the + // alias resolves to is live and declared by another binding. core's + // collision check refuses this before a generation applies; this is the + // durable backstop for two replicas reconciling the same pass. + ErrSolutionHostBindingRouteHeld = errors.New("solution host binding route alias is held by another binding") +) + +// solutionIDPattern is the registry's own identity rule, restated here because +// the host resolves a registry key from a route alias and must refuse an alias +// that could not be one. It is the single-segment gateway route identity. +var solutionIDPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$`) + +// maxSolutionIDLength matches the registry's own bound on solution_id. +const maxSolutionIDLength = 128 + +// SolutionHostBindingGeneration is one generation of one document: what it +// declares, and the digest that identifies it. The document is kept whole, not +// just its digest, because the host must be able to say what it is running and +// what it has been asked to run. +type SolutionHostBindingGeneration struct { + Generation uint64 + Digest string + Document string + At time.Time +} + +// SolutionHostBindingApplied is the generation this host reconciled, and what it +// reconciled it into. +type SolutionHostBindingApplied struct { + SolutionHostBindingGeneration + // Removed records that the applied generation was a tombstone. It is not an + // absence: the binding ID and its generation remain, so a late or replayed + // older generation is still refused after a removal. + Removed bool + // Routes are the aliases the applied generation holds. + Routes []string + // SolutionID is the registry record the applied generation reconciled into. + // A tombstone generation declares no route, so this is the only way it knows + // which record to withdraw. + SolutionID string + // Release is publisher/name@version of the applied generation. + Release string + // Domain is the ownership domain the applied generation declared. + // + // It is persisted rather than re-derived because it decides WHO MAY CHANGE + // this record: core refuses a later generation for the binding that arrives + // under a different domain, which is what stops one accepted delivery taking + // over a binding another delivery owns. Re-deriving it from the host's own + // configured domain list would answer "a domain this host accepts" instead + // of "the domain this binding was claimed under", and those differ exactly + // when it matters — a host accepting two domains. + Domain string +} + +// SolutionHostBindingRecord is the host's durable record for one binding ID. +type SolutionHostBindingRecord struct { + BindingID string + HostCoordinate string + HostComponent string + Desired *SolutionHostBindingGeneration + Applied *SolutionHostBindingApplied + PendingReason string + PendingSince *time.Time + UpdatedAt time.Time +} + +// AppliedState is the record in the shape core judges a document against. A +// binding with nothing applied contributes no state, which is how core is told +// "never seen". +func (r *SolutionHostBindingRecord) AppliedState() (solutionhost.Applied, bool) { + if r == nil || r.Applied == nil { + return solutionhost.Applied{}, false + } + return solutionhost.Applied{ + Binding: r.BindingID, + Generation: r.Applied.Generation, + Digest: r.Applied.Digest, + Domain: r.Applied.Domain, + Routes: r.Applied.Routes, + Removed: r.Applied.Removed, + }, true +} + +// PendingGeneration is the generation delivery is showing that this host has not +// applied, or zero when desired and applied agree. It is what an operator reads +// beside PendingReason. +func (r *SolutionHostBindingRecord) PendingGeneration() uint64 { + if r == nil || r.Desired == nil { + return 0 + } + if r.Applied != nil && r.Applied.Generation == r.Desired.Generation && r.Applied.Digest == r.Desired.Digest { + return 0 + } + return r.Desired.Generation +} + +// SolutionHostBindingDocument is one delivered document as it was read. Source +// is where it came from and appears only in operator-facing messages; identity +// comes from the document's own binding field, never from a file name. +type SolutionHostBindingDocument struct { + Source string + + // Data is a SIGNED CARRIER — core's {schema, document, bundle} — not a bare + // presence document. Delivery signs at publish and the host verifies at + // admission, so the bytes that cross the mount are the bytes the signature + // covers. A bare document now fails to parse here rather than being + // admitted unverified, which is the point: core cd443989 removed every path + // from unattested bytes to a host judgement, and this is the host side of + // that removal. + Data []byte +} + +// SolutionHostBindingSource is the delivered desired set. +// +// It returns an error rather than an empty set when the mount cannot be read. +// Removal is a tombstone generation precisely so that a lost, unmounted or +// unreadable mount can never be reconciled as "remove everything". +type SolutionHostBindingSource interface { + Documents(ctx context.Context) ([]SolutionHostBindingDocument, error) +} + +// SolutionHostBindingReconciler drives one pass and, when started, repeats it. +type SolutionHostBindingReconciler struct { + service *Service + source SolutionHostBindingSource + host solutionhost.Host + + // verifier is this host's attestation check. core holds no verifier and + // never will: signing is keyless over a workload identity, so verifying is + // a trust root and an identity policy that belong to the deployment, not to + // a library every binary imports. + verifier solutionhost.BundleVerifier + + interval time.Duration + now func() time.Time + + // activation answers whether delivered authority is active for a binding at + // a build. It lives on the reconciler because the reconciler already holds + // every policy input activation needs — this host's coordinate, the + // ownership domains it accepts, which signer may speak for which, and the + // bundle verifier — and a second copy of any of them would be a second + // answer to "what does this host accept". + activation *SolutionAuthorityActivation + + // approvedBuilds is the execution authority this host serves to the mint, + // rebuilt from the same inbox in the same pass. + // + // ONE PASS FOR BOTH HALVES, deliberately. Presence and authority arrive in + // one inbox and describe one delivery, so refreshing them on separate + // schedules opens a window in which a generation is applied while the build + // it approves is still the previous one — and that window is exactly where + // a superseded build is mintable. Nothing can drift if there is only one + // pass. + approvedBuilds *ApprovedBuildReconciler + + mu sync.Mutex + cancel context.CancelFunc + done chan struct{} +} + +// SolutionHostBindingReconcilerConfig is everything one host's reconciler needs +// to judge delivery. Every field but Interval is required, and each absence is +// refused by name rather than defaulted, because every one of them is a +// different way for the host to accept a binding it should not. +type SolutionHostBindingReconcilerConfig struct { + // Source is the delivered desired set, as signed carriers. + Source SolutionHostBindingSource + + // Verifier is this host's attestation check over a carrier's bundle. It is + // required: without one there is no way to construct the verified value + // core's Admit takes, so a host with no verifier admits nothing at all + // rather than falling back to admitting everything. + Verifier solutionhost.BundleVerifier + + // Coordinate is the host this reconciler answers for. + Coordinate string + + // Domains are the ownership domains this host accepts delivery under. + Domains []string + + // DomainsBySigner is which of those domains each attested signer identity + // may speak for, keyed by the identity Verifier returns. A document asserts + // its own ownership domain, so without this policy any signer the host + // accepted at all could deliver under any domain the host accepted and take + // over bindings in it. + DomainsBySigner map[string][]string + + // Interval between passes. Zero takes the default. + Interval time.Duration + + // Envelope is the AUTHORITY CEILING delivered to this host: who may hold + // which binding, and which builds are approved, at one revision. + // + // Optional, and its absence is not a weaker ceiling. A zero revision means + // no ceiling is delivered, and every activation then refuses with + // ErrSolutionAuthorityCeilingUnavailable — the host has decided nothing + // rather than decided that a grant is absent. A host that reconciles + // presence with no ceiling delivered is a correct, complete deployment: + // authority is simply not a question it can answer, and making the presence + // half depend on the authority half being configured would be the wrong + // coupling. + // + // It is never read out of a delivered document. core: "an envelope a + // document carried would be a document declaring its own ceiling", and + // ValidateAgainst tests a document's grants and approved build against the + // envelope's — so an envelope assembled from the delivery tree answers + // itself for every document in it. + Envelope solutionhost.Envelope +} + +// NewSolutionHostBindingReconciler builds the reconciler for one host +// coordinate. The coordinate is required: core refuses a document that targets +// another host, and a host that did not know its own coordinate would reconcile +// whatever it was handed. +func NewSolutionHostBindingReconciler( + service *Service, config SolutionHostBindingReconcilerConfig, +) (*SolutionHostBindingReconciler, error) { + source, domains, interval := config.Source, config.Domains, config.Interval + if service == nil || source == nil { + return nil, errors.New("solution host binding reconciler requires a service and a source") + } + if config.Verifier == nil { + return nil, errors.New("solution host binding reconciler requires a bundle verifier; a host that cannot verify a carrier must admit nothing, not everything") + } + if config.Coordinate == "" { + return nil, errors.New("solution host binding reconciler requires this host's coordinate") + } + // Core requires the accepted ownership domains whenever a coordinate is set, + // and refuses a document from an unstated one. The reason is that the applied + // record cannot bound a binding's FIRST generation — there is nothing to + // compare against yet — so without this, any delivery could claim any unseen + // binding ID under a domain of its choosing and own it from then on. It is the + // host's to declare because core cannot know which delivery is entitled to a + // name it has never seen. + if len(domains) == 0 { + return nil, errors.New("solution host binding reconciler requires the ownership domains this host accepts delivery from") + } + // Same reasoning one axis over, and core refuses the call without it: an + // unstated signer policy would let every accepted signer claim every + // accepted domain, which makes the domain list decorative. + if len(config.DomainsBySigner) == 0 { + return nil, errors.New("solution host binding reconciler requires the ownership domains each signer identity may deliver under") + } + if interval <= 0 { + interval = SolutionHostBindingReconcileInterval + } + reconciler := &SolutionHostBindingReconciler{ + service: service, + source: source, + verifier: config.Verifier, + host: solutionhost.Host{ + Coordinate: config.Coordinate, + Domains: domains, + DomainsBySigner: config.DomainsBySigner, + Reserved: []string{SolutionHostReservedRouteNamespace}, + }, + interval: interval, + now: func() time.Time { return time.Now().UTC() }, + } + activation, err := newSolutionAuthorityActivation(reconciler, config.Envelope) + if err != nil { + return nil, err + } + reconciler.activation = activation + approvedBuilds, err := NewApprovedBuildReconciler(activation) + if err != nil { + return nil, err + } + reconciler.approvedBuilds = approvedBuilds + return reconciler, nil +} + +// ApprovedBuilds is the ExecutionAuthority a host wires into the mint: what +// this host approves for a principal, as the last successful pass established +// it. +// +// Exposed from here for the same reason AuthorityActivation is — the reconciler +// already holds every policy input, and a second reader built beside it would +// be a second answer to what this host approves. +func (r *SolutionHostBindingReconciler) ApprovedBuilds() ExecutionAuthority { + if r == nil { + return nil + } + return r.approvedBuilds +} + +// AuthorityActivation is the seam a capability-minting path asks "is the +// delivered authority over this binding active for the build I have established +// this caller is running". +// +// It is exposed rather than consumed here because the host does not APPLY +// authority to anything: there is no durable state an activation reconciles +// into, and a reconcile pass that logged the answer every interval would be +// noise rather than an effect. The answer is a read, and the caller that needs +// it is the one that has independently established a running build. +func (r *SolutionHostBindingReconciler) AuthorityActivation() *SolutionAuthorityActivation { + if r == nil { + return nil + } + return r.activation +} + +// RunOnce reads the mount and reconciles one pass. +// +// The pass is ordered so that a refusal can never disturb what is running. +// Everything is read and judged before anything is written: the mount, then the +// durable applied state, then core's verdict on the whole desired set. Only then +// is desired state recorded — for every delivered binding, admitted or not, so a +// withheld generation and its reason are visible — and only then is an admitted +// generation applied. +func (r *SolutionHostBindingReconciler) RunOnce(ctx context.Context) error { + documents, err := r.source.Documents(ctx) + if err != nil { + // An unreadable mount is not an empty desired set. Nothing is recorded + // and nothing is applied: the last generation that passed keeps running. + return fmt.Errorf("read delivered solution host bindings: %w", err) + } + + verified, unparsed := verifySolutionHostBindingDocuments(ctx, r.verifier, documents) + + records, err := r.service.ListSolutionHostBindings(ctx) + if err != nil { + return err + } + host := r.host + host.Applied = appliedStates(records) + + admission := admitSolutionHostBindings(host, verified) + + // Desired state is recorded for every delivered document before anything is + // applied, so an operator sees what delivery is showing even when none of it + // passed. A document that did not parse names no binding it can be recorded + // against; those are reported to the caller. + // A generation core reports as already applied is settled: recording desired + // state for it may clear a reason left by an earlier pass, because there is + // nothing outstanding. A generation about to be applied is not settled — the + // apply owns its reason. + settled := make(map[string]bool, len(admission.Accepted)) + for _, accepted := range admission.Accepted { + settled[accepted.Document.Binding] = accepted.Decision == solutionhost.DecisionCurrent + } + var failures []error + for _, one := range verified { + document := one.Document + reason := admission.Withheld[document.Binding] + if err := r.service.recordSolutionHostBindingDesired( + ctx, document, reason, settled[document.Binding], r.now(), + ); err != nil { + failures = append(failures, fmt.Errorf("record desired generation for binding %q: %w", document.Binding, err)) + } + } + + for _, applied := range orderSolutionHostBindingApplies(admission.Accepted, records) { + if applied.Decision != solutionhost.DecisionApply { + // DecisionCurrent: this exact generation is already applied. A host + // re-reads its mount every pass, so this is the normal answer and + // not an error. Desired state was recorded above; there is nothing + // to change. + continue + } + if err := r.service.applySolutionHostBinding( + ctx, applied.Delivered, r.host.Coordinate, r.host.Domains, r.host.DomainsBySigner, r.now(), + ); err != nil { + // One binding's apply failing leaves every other binding's progress + // intact: each apply is its own transaction, because each binding is + // independent desired state. The reason is recorded as this + // binding's pending state so it is visible rather than log-only. + if recordErr := r.service.recordSolutionHostBindingRefusal( + ctx, applied.Document.Binding, err.Error(), r.now(), + ); recordErr != nil { + failures = append(failures, recordErr) + } + failures = append(failures, fmt.Errorf("apply binding %q generation %d: %w", + applied.Document.Binding, applied.Document.Generation, err)) + } + } + + // THE APPROVED-BUILD VIEW, from the same inbox, in the same pass. It is + // refreshed AFTER the applies rather than before: a generation that has just + // been applied is the one whose approved build the mint will be asked about, + // and refreshing first would serve the previous answer for one whole + // interval. Its failures join the pass's, so a principal this host refused + // to approve is visible in the same place as a binding it refused to apply. + if r.approvedBuilds != nil { + if err := r.approvedBuilds.RunOnce(ctx); err != nil { + failures = append(failures, fmt.Errorf("refresh the approved-build view: %w", err)) + } + } + + // A document core refused to parse still gets its refusal recorded against + // the binding it names, so "delivery is shipping something this host cannot + // read" is visible beside the generation it last applied rather than only in + // a log line. + for _, problem := range unparsed { + failures = append(failures, problem.err) + if problem.binding == "" { + continue + } + if err := r.service.recordSolutionHostBindingRefusal( + ctx, problem.binding, problem.err.Error(), r.now(), + ); err != nil && !errors.Is(err, ErrSolutionHostBindingNotDeclared) { + failures = append(failures, err) + } + } + return errors.Join(failures...) +} + +// Start runs a pass now and then on every interval until Shutdown. A failing +// pass is logged and the loop continues: the mount is re-read every interval, so +// a transient failure resolves itself and a persistent one keeps saying so. +func (r *SolutionHostBindingReconciler) Start(parent context.Context) { + r.mu.Lock() + if r.cancel != nil { + r.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(parent) + r.cancel = cancel + r.done = make(chan struct{}) + done := r.done + r.mu.Unlock() + + go func() { + defer close(done) + run := func() { + if err := r.RunOnce(ctx); err != nil && !errors.Is(err, context.Canceled) { + wool.Get(ctx).In("business.solution_host_bindings").Warn( + "solution host binding reconcile pass failed", wool.ErrField(err)) + } + } + run() + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } + }() +} + +// Shutdown stops the loop and waits for the pass in flight. +func (r *SolutionHostBindingReconciler) Shutdown(ctx context.Context) error { + r.mu.Lock() + cancel, done := r.cancel, r.done + r.mu.Unlock() + if cancel == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// verifySolutionHostBindingDocuments verifies every delivered carrier, keeping +// the ones this host's attestation check accepts and describing the ones it does +// not. +// +// Every refusal here is one of three things, and they are deliberately not +// distinguished in what is recorded: not a carrier, a carrier whose bundle does +// not verify, or a verified payload core will not parse. An operator needs to +// know delivery is shipping something this host will not admit; which of the +// three it is tells an attacker which half of the door it got past, so the +// reason recorded against a binding stays coarse while the returned error — which +// goes to the log, not the row — carries core's own wording. +// +// Attribution of a refusal to a binding ID reads UNVERIFIED bytes, and that is +// a deliberate, bounded choice. A carrier that fails verification has no +// attested signer by definition, so there is no verified name to attribute it +// to; recording nothing would hide "delivery is broken for this binding" in a +// log line. What it can do is write a refusal REASON onto an existing binding's +// row — it cannot change what that binding is running, cannot create a row +// (recordSolutionHostBindingRefusal answers ErrSolutionHostBindingNotDeclared +// for an undeclared binding), and cannot reach Admit. So the residual is a +// misleading reason string on a row whose applied generation is untouched, which +// is why the attribution is kept and why the reason is coarse. +func verifySolutionHostBindingDocuments( + ctx context.Context, verifier solutionhost.BundleVerifier, documents []SolutionHostBindingDocument, +) ([]deliveredSolutionHostBinding, []unparsedSolutionHostBinding) { + verified := make([]deliveredSolutionHostBinding, 0, len(documents)) + var problems []unparsedSolutionHostBinding + for _, delivered := range documents { + carrier, err := solutionhost.ParseSigned(delivered.Data) + if err != nil { + problems = append(problems, unparsedSolutionHostBinding{ + err: fmt.Errorf("delivered solution host binding %s: %w", delivered.Source, err), + }) + continue + } + one, err := solutionhost.VerifyDelivered(ctx, carrier, verifier) + if err != nil { + problems = append(problems, unparsedSolutionHostBinding{ + binding: attributeSolutionHostBinding(carrier.Document), + err: fmt.Errorf("delivered solution host binding %s: %w", delivered.Source, err), + }) + continue + } + document, err := one.Document() + if err != nil { + // core parsed these same bytes inside VerifyDelivered, so this is + // unreachable by construction rather than merely unlikely. It is + // still handled as a refusal: a host that cannot read back what it + // just verified must admit nothing from it, and the alternative is + // a nil document reaching admission. + problems = append(problems, unparsedSolutionHostBinding{ + binding: attributeSolutionHostBinding(carrier.Document), + err: fmt.Errorf("delivered solution host binding %s: %w", delivered.Source, err), + }) + continue + } + verified = append(verified, deliveredSolutionHostBinding{Delivered: one, Document: document}) + } + sort.Slice(verified, func(i, j int) bool { return verified[i].Document.Binding < verified[j].Document.Binding }) + return verified, problems +} + +// deliveredSolutionHostBinding pairs an attested carrier with the document +// re-derived from its attested bytes, derived ONCE per pass. +// +// core 67ee7220 made Delivered.Document() a derivation returning an error +// rather than a field returning a pointer, because the pointer form let a +// caller mutate what Admit would afterwards consume — the attestation covered +// one generation and admission consumed another, with nothing anywhere saying +// so. Two consequences land here. A derivation that can fail cannot be read +// inside a sort comparator, and re-reading it per use would re-parse and +// canonically round-trip the same bytes once for every question asked of them. +// +// Holding the result is safe in a way holding the old pointer was not: Admit +// re-derives from the attested payload itself, so this copy is what the pass +// READS and cannot become what the host admits. That is the property core's +// redesign bought, and pairing the two values here is what makes the pass use +// one document rather than a fresh one per call site. +type deliveredSolutionHostBinding struct { + Delivered *solutionhost.Delivered + Document *solutionhost.SolutionHostBinding +} + +// unparsedSolutionHostBinding is a delivered document core would not parse, and +// the binding it claims to be for. +type unparsedSolutionHostBinding struct { + binding string + err error +} + +// attributeSolutionHostBinding reads only the binding ID out of a document this +// host refused, so the refusal can be recorded against it. It never produces a +// document and its result is never admitted. Its input is unverified by +// construction — see verifySolutionHostBindingDocuments for why that is bounded. +func attributeSolutionHostBinding(data []byte) string { + var named struct { + Binding string `yaml:"binding"` + } + if err := yaml.Unmarshal(data, &named); err != nil { + return "" + } + return named.Binding +} + +func appliedStates(records []*SolutionHostBindingRecord) []solutionhost.Applied { + applied := make([]solutionhost.Applied, 0, len(records)) + for _, record := range records { + if state, ok := record.AppliedState(); ok { + applied = append(applied, state) + } + } + return applied +} + +// orderSolutionHostBindingApplies puts the generations that RELEASE a registry +// key before the ones that claim one. +// +// A route alias moving from one binding to another is two applies, and each +// binding is its own transaction. Applying the claimant first would collide with +// the key the previous holder still records — the partial unique index refuses +// it — and the hand-over would only complete on a later pass. Releasing first +// completes it in one. +func orderSolutionHostBindingApplies( + accepted []SolutionHostBindingDecision, records []*SolutionHostBindingRecord, +) []SolutionHostBindingDecision { + held := make(map[string]string, len(records)) + for _, record := range records { + if record.Applied != nil && !record.Applied.Removed && record.Applied.SolutionID != "" { + held[record.BindingID] = record.Applied.SolutionID + } + } + releases := func(candidate SolutionHostBindingDecision) bool { + current, holds := held[candidate.Document.Binding] + if !holds { + return false + } + if candidate.Document.Removed { + return true + } + for _, alias := range candidate.Document.Aliases() { + if alias == current { + return false + } + } + return true + } + ordered := make([]SolutionHostBindingDecision, 0, len(accepted)) + for _, candidate := range accepted { + if releases(candidate) { + ordered = append(ordered, candidate) + } + } + for _, candidate := range accepted { + if !releases(candidate) { + ordered = append(ordered, candidate) + } + } + return ordered +} + +// solutionHostBindingRegistryKey resolves the registry record a present +// generation reconciles into. +// +// The key is the binding's route alias, because the alias is what this host +// routes on: it is the in /solutions//* and the key the frontend loads +// a remote under. The binding ID is deliberately not the key — it identifies a +// deployment instance, may carry characters a path segment may not, and a second +// instance of the same solution inherits nothing from the first, alias included. +func solutionHostBindingRegistryKey(document *solutionhost.SolutionHostBinding) (string, error) { + aliases := document.Aliases() + if len(aliases) != 1 { + return "", fmt.Errorf("%w: binding %q declares %d", + ErrSolutionHostBindingRouteRequired, document.Binding, len(aliases)) + } + alias := aliases[0] + if !solutionIDPattern.MatchString(alias) || len(alias) > maxSolutionIDLength { + return "", fmt.Errorf("%w: binding %q declares route %q, which is not one lowercase path segment", + ErrSolutionHostBindingRouteNotAddressable, document.Binding, alias) + } + return alias, nil +} diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go b/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go new file mode 100644 index 000000000..cdc37ead2 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_host_bindings_apply.go @@ -0,0 +1,752 @@ +package business + +import ( + "context" + "fmt" + "time" + + "github.com/codefly-dev/core/solutionhost" +) + +// Reconciling an admitted generation into the durable registry (issue #952). +// +// The registry is a projection of admitted declarations, with a registry-wide +// revision sequence and tombstones. Runtime processes cannot write presence. +// +// Every apply is one transaction over one binding, and it re-decides under the +// row lock rather than trusting the verdict the pass computed from a snapshot. +// That is what makes two replicas converge without applying a generation twice: +// both read the same mount and both propose the same generation, and the second +// to take the lock finds it already applied and writes nothing. + +// ListSolutionHostBindings returns every durable binding record, ordered by +// binding ID. One host holds tens of bindings at most, so the reconciler reads +// the whole set on every pass rather than tailing changes. +func (s *Service) ListSolutionHostBindings(ctx context.Context) ([]*SolutionHostBindingRecord, error) { + var records []*SolutionHostBindingRecord + err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + records, err = s.store.ListSolutionHostBindings(ctx) + return err + }) + if err != nil { + return nil, fmt.Errorf("list solution host bindings: %w", err) + } + return records, nil +} + +// recordSolutionHostBindingDesired records what delivery is showing this host for +// one binding, and why it is not applied when it is not. +// +// It writes desired state only. A binding whose document was withheld keeps the +// generation it had applied, which is the whole point of preparing before +// activating: the last generation that passed every check goes on serving, and +// what delivery wants plus the reason it was refused sit beside it. +func (s *Service) recordSolutionHostBindingDesired( + ctx context.Context, document *solutionhost.SolutionHostBinding, + withheldReason string, settled bool, now time.Time, +) error { + digest, err := document.Digest() + if err != nil { + return err + } + canonical, err := solutionhost.Marshal(document) + if err != nil { + return err + } + desired := SolutionHostBindingGeneration{ + Generation: document.Generation, + Digest: digest, + Document: string(canonical), + At: now, + } + return s.store.WithControlPlane(ctx, func(ctx context.Context) error { + record, err := s.store.GetSolutionHostBindingForUpdate(ctx, document.Binding) + if err != nil { + return err + } + if record == nil { + record = &SolutionHostBindingRecord{BindingID: document.Binding} + } + record.HostCoordinate = document.Host.Coordinate + record.HostComponent = document.Host.Component + record.Desired = &desired + // A withheld document's reason is recorded here. An admitted one's is + // NOT cleared here unless the generation is already applied (settled): + // the apply that follows either clears it or replaces it, and clearing + // it first would reset pending_since on every pass — so a refusal stuck + // for a day would read as one that started seconds ago, which is the + // opposite of what this field is for. + if withheldReason != "" || settled { + applyPendingReason(record, withheldReason, now) + } + record.UpdatedAt = now + if err := s.store.SaveSolutionHostBinding(ctx, record); err != nil { + return err + } + if withheldReason == "" { + return nil + } + return s.store.RecordSolutionGenerationDecision(ctx, &SolutionGenerationDecision{ + BindingID: document.Binding, + Generation: document.Generation, + Digest: digest, + Decision: SolutionGenerationRefused, + Reason: withheldReason, + Release: document.Release.Identity(), + DecidedAt: now, + }) + }) +} + +// recordSolutionHostBindingRefusal records a reason against a binding without +// touching desired or applied state. It is what an apply that failed, and a +// document this host could not even parse, leave behind. +func (s *Service) recordSolutionHostBindingRefusal( + ctx context.Context, bindingID, reason string, now time.Time, +) error { + if reason == "" { + return nil + } + return s.store.WithControlPlane(ctx, func(ctx context.Context) error { + record, err := s.store.GetSolutionHostBindingForUpdate(ctx, bindingID) + if err != nil { + return err + } + if record == nil { + // Nothing has ever been delivered for this binding that this host + // could read, so there is no record to hang a reason on. The caller + // still reports it. + return ErrSolutionHostBindingNotDeclared + } + record.PendingReason = reason + record.PendingSince = pendingSince(record, now) + record.UpdatedAt = now + if err := s.store.SaveSolutionHostBinding(ctx, record); err != nil { + return err + } + // The trail keeps a superseded refusal, which the binding row cannot: + // pending_reason is overwritten by the next pass's answer. + if record.Desired != nil { + return s.store.RecordSolutionGenerationDecision(ctx, &SolutionGenerationDecision{ + BindingID: bindingID, + Generation: record.Desired.Generation, + Digest: record.Desired.Digest, + Decision: SolutionGenerationRefused, + Reason: reason, + DecidedAt: now, + }) + } + return nil + }) +} + +// applyPendingReason sets or clears a binding's blocking reason, keeping the +// instant the CURRENT reason first appeared so an operator can tell a refusal +// that has just started from one that has been stuck for a day. Every pass +// re-records a standing refusal, so the instant has to survive a re-record — +// replacing it each time would make a permanently stuck binding look fresh. +func applyPendingReason(record *SolutionHostBindingRecord, reason string, now time.Time) { + if reason == "" { + record.PendingReason = "" + record.PendingSince = nil + return + } + record.PendingSince = pendingSince(record, now) + record.PendingReason = reason +} + +func pendingSince(record *SolutionHostBindingRecord, now time.Time) *time.Time { + if record.PendingSince != nil { + return record.PendingSince + } + since := now + return &since +} + +// applySolutionHostBinding reconciles one admitted generation. It is the pass's, +// not a service operation: reconciling one binding without the set-wide admission +// that preceded it would apply a generation core never approved. +// +// It re-runs core's admission against the LOCKED record rather than the +// snapshot the pass judged, because another replica may have applied this exact +// generation in between. core answers DecisionCurrent for that, which is not an +// error and writes nothing — only a rewrite of an applied generation is. +func (s *Service) applySolutionHostBinding( + ctx context.Context, delivered *solutionhost.Delivered, + coordinate string, domains []string, domainsBySigner map[string][]string, now time.Time, +) error { + // Re-derived here rather than taken from the pass: apply owns its own + // read of the attested bytes, so there is no second value a caller could + // have adjusted between admission and the write. + document, err := delivered.Document() + if err != nil { + return err + } + digest, err := document.Digest() + if err != nil { + return err + } + canonical, err := solutionhost.Marshal(document) + if err != nil { + return err + } + // Resolved before the transaction opens: a present generation this host + // cannot address is refused without taking a lock, and the refusal names the + // route rather than a database error. + var solutionID string + if !document.Removed { + if solutionID, err = solutionHostBindingRegistryKey(document); err != nil { + return err + } + } + + // A withdrawal CLOSES the installable target and revokes every active + // installation naming it. That is a narrowing of authority, so it must be + // witnessed by the external policy log before it takes effect, and the + // append has to happen BEFORE this transaction opens — a slow log must not + // hold database locks. Whether a close is coming is only knowable from the + // live target, so it is read here, unlocked; the locked read inside the + // transaction is still the one that decides. + // + // The two reads can disagree, and only one direction is dangerous. If this + // one saw a live target and the locked one finds none, another replica + // closed it first: the appended entry is still true and commits. If this + // one saw none and the locked one finds one, a close would run with nothing + // appended for it — so reconcileSolutionTarget REFUSES an unwitnessed + // close, and the next pass, which now reads a live target, carries it. + var witnessedTargetClose string + apply := func(ctx context.Context) error { + record, err := s.store.GetSolutionHostBindingForUpdate(ctx, document.Binding) + if err != nil { + return err + } + if record == nil { + record = &SolutionHostBindingRecord{BindingID: document.Binding} + } + // core judges this one document against this one binding's applied + // state. The set-wide rules — alias collisions, a binding declared + // twice — were settled by the pass, and the partial unique index on the + // registry key is the durable backstop for two replicas racing. + host := solutionhost.Host{ + Coordinate: coordinate, + Domains: domains, + DomainsBySigner: domainsBySigner, + Reserved: []string{SolutionHostReservedRouteNamespace}, + } + if state, ok := record.AppliedState(); ok { + host.Applied = []solutionhost.Applied{state} + } + // The VERIFIED value, not the document read out of it. core's Admit + // takes *Delivered precisely so a second judgement cannot be made on + // bytes whose attestation was checked once and then dropped on the way + // here. + admissions, err := host.Admit(delivered) + if err != nil { + // One document, so core's set-level error and this document's own + // refusal are the same thing; returning it is what leaves the + // running generation untouched. + return err + } + if admissions[0].Decision == solutionhost.DecisionCurrent { + // Another replica applied it, or this pass raced its own previous + // one. Nothing to do, and deliberately not an error — but it is + // recorded, because "delivery kept saying the same thing" and + // "delivery stopped" are different facts and the append is + // idempotent on (binding, generation, digest, decision), so a poll + // does not grow the trail. + return s.store.RecordSolutionGenerationDecision(ctx, &SolutionGenerationDecision{ + BindingID: document.Binding, + Generation: document.Generation, + Digest: digest, + Decision: SolutionGenerationCurrent, + Release: document.Release.Identity(), + DecidedAt: now, + }) + } + + // The registry revision this apply produced. It is recorded on the + // generation-history row so the trail says which registry state the + // generation became, rather than carrying a zero that reads like a + // revision nobody issued. A tombstone's withdrawal produces one too. + var appliedRegistryRevision *int64 + + previous := record.Applied + // The identity an organisation installs moves with this transaction, so a + // reused alias cannot carry an installation across to another binding + // (solution_targets.go). + targetID, err := s.reconcileSolutionTarget(ctx, document, solutionID, witnessedTargetClose, now) + if err != nil { + return err + } + if document.Removed { + withdrawn, err := s.withdrawDeclaredSolutionRegistration(ctx, record, document, targetID, now) + if err != nil { + return err + } + if withdrawn > 0 { + appliedRegistryRevision = &withdrawn + } + } else { + if previous != nil && !previous.Removed && + previous.SolutionID != "" && previous.SolutionID != solutionID { + // The generation moved this binding's route. The alias it held + // is withdrawn in the same transaction that claims the new one, + // so there is no instant at which both are served. + if _, err := s.withdrawDeclaredSolutionRegistration(ctx, record, document, targetID, now); err != nil { + return err + } + } + produced, err := s.declareSolutionRegistration(ctx, document, solutionID, targetID, now) + if err != nil { + return err + } + appliedRegistryRevision = &produced + } + + record.HostCoordinate = document.Host.Coordinate + record.HostComponent = document.Host.Component + generation := SolutionHostBindingGeneration{ + Generation: document.Generation, + Digest: digest, + Document: string(canonical), + At: now, + } + record.Desired = &generation + // The half core owns is built by core. AppliedFrom is defined as "the + // record a host persists after applying a document", so deriving those + // fields here would be a second copy of its rules — and the copy is what + // drifts: presence v2 made the ownership domain a required part of this + // record, and a host that assembled it by hand fed core an applied state + // with no domain, which core refuses. The symptom was not a widened + // check. It was a reconciler that applied its first generation and then + // refused the WHOLE set on every pass afterwards, because one unusable + // applied record makes the set unjudgeable. + // + // Reading it from core instead means the next required field is a + // compile error or a loud refusal here, not a silent omission. + owned, err := solutionhost.AppliedFrom(document) + if err != nil { + return fmt.Errorf("build applied record for binding %q: %w", document.Binding, err) + } + record.Applied = &SolutionHostBindingApplied{ + SolutionHostBindingGeneration: generation, + Removed: owned.Removed, + Routes: owned.Routes, + Domain: owned.Domain, + // Release is the host's own column: core's Applied does not carry it. + // It identifies the release whose presence this generation declares. + Release: document.Release.Identity(), + } + if document.Removed { + // A tombstone keeps the key it withdrew, so the removal stays + // attributable to the record it removed and a later binding + // claiming that alias is not blocked by it. + if previous != nil { + record.Applied.SolutionID = previous.SolutionID + } + } else { + record.Applied.SolutionID = solutionID + } + applyPendingReason(record, "", now) + record.UpdatedAt = now + if err := s.store.SaveSolutionHostBinding(ctx, record); err != nil { + return err + } + decided := SolutionGenerationApplied + if document.Removed { + decided = SolutionGenerationTombstoned + } + // The target reconciliation above returns the identity it decided on, + // INCLUDING a tombstone's closed one. Re-reading the live target here + // is what dropped it: by this point the close has already committed in + // this transaction, so the read finds nothing and the row that records + // a withdrawal loses the identity it withdrew. + if err := s.store.RecordSolutionGenerationDecision(ctx, &SolutionGenerationDecision{ + BindingID: document.Binding, + TargetID: targetID, + Generation: document.Generation, + Digest: digest, + Decision: decided, + Release: record.Applied.Release, + RegistryRevision: appliedRegistryRevision, + DecidedAt: now, + }); err != nil { + return err + } + return s.emitTx(ctx, solutionHostBindingActor(record), "system", + EventSolutionHostBindingApplied, "solution", solutionHostBindingResource(record), "", + map[string]any{ + "binding_id": document.Binding, + "solution_id": record.Applied.SolutionID, + "generation": int64(document.Generation), + "digest": digest, + "release": record.Applied.Release, + "removed": document.Removed, + "routes": record.Applied.Routes, + }) + } + + // A withdrawal: find the target this binding has live, and run the whole + // transaction under the policy log when there is one to close. + if document.Removed { + live, err := s.liveSolutionTargetForBinding(ctx, document.Binding) + if err != nil { + return err + } + if live != nil { + witnessedTargetClose = newPolicyLogOperationID("close-solution-target") + return s.WithPolicyLoggedNarrowing(ctx, closedSolutionTargetPolicyLogEntry( + witnessedTargetClose, live.ID, document.Binding, live.SolutionID, document.Generation, + ), apply) + } + } + return s.store.WithControlPlane(ctx, apply) +} + +// liveSolutionTargetForBinding is the unlocked read that decides whether a +// withdrawal has a target to close, so the policy log append can happen before +// the apply transaction opens. +// +// It filters the whole target listing rather than taking a lock, because the +// locked read inside the transaction is the one that decides and a second lock +// here would only widen the window it holds. The listing is one row per +// solution instance this host has ever presented, which is the same set the +// operator surface already pages through. +func (s *Service) liveSolutionTargetForBinding(ctx context.Context, bindingID string) (*SolutionTarget, error) { + var live *SolutionTarget + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + targets, err := s.store.ListSolutionTargets(ctx) + if err != nil { + return err + } + for _, target := range targets { + if target.BindingID == bindingID && target.Live() { + live = target + return nil + } + } + return nil + }); err != nil { + return nil, err + } + return live, nil +} + +// declareSolutionRegistration records the binding and immutable target that own +// this alias. Only observations for that same declared target may survive. +func (s *Service) declareSolutionRegistration( + ctx context.Context, document *solutionhost.SolutionHostBinding, solutionID, targetID string, now time.Time, +) (int64, error) { + current, err := s.store.GetSolutionRegistrationForUpdate(ctx, solutionID) + if err != nil { + return 0, err + } + if current != nil && current.Declared != nil && + current.Declared.BindingID != document.Binding && current.TombstonedAt == nil { + // A live record another binding declared. core refuses this before a + // generation applies; reaching it means two replicas judged different + // snapshots, and refusing here is what stops the second from stealing + // the route. A TOMBSTONED declared record may be taken over: its + // declaration withdrew the alias, and the binding that held it keeps + // its own tombstone generation. + return 0, fmt.Errorf("%w: %q is declared by binding %q", + ErrSolutionHostBindingRouteHeld, solutionID, current.Declared.BindingID) + } + revision, err := s.store.NextSolutionRegistryRevision(ctx) + if err != nil { + return 0, err + } + kind := SolutionDeclaredKind(document.Kind) + if !kind.Valid() { + // Core refuses any kind outside its own two before a document is + // admitted, so reaching this means core's vocabulary grew and this host + // has no surface for the new value. Refusing names the binding and leaves + // the generation it had applied running; writing the record anyway would + // produce a row no routing surface serves and no operator can explain. + return 0, fmt.Errorf("%w: binding %q declares kind %q", + ErrSolutionHostBindingKindNotRoutable, document.Binding, document.Kind) + } + declared := &SolutionDeclaredBinding{ + BindingID: document.Binding, + Generation: document.Generation, + Release: document.Release.Identity(), + TargetID: targetID, + Kind: kind, + } + next := &SolutionRegistration{ + SolutionID: solutionID, + Publisher: solutionRegistrationPublisher(solutionID), + Revision: revision, + UpdatedAt: now, + Declared: declared, + } + if current != nil && current.Declared != nil && current.Declared.TargetID == targetID { + next.Publisher = current.Publisher + // A tombstone cleared the halves; a live record keeps the ones it has. + next.Frontend = current.Frontend + next.Backend = current.Backend + } + if err := s.store.SaveSolutionRegistration(ctx, next); err != nil { + return 0, err + } + // The declared set just changed, so the audience vocabulary's snapshot is + // stale. Invalidating here rather than waiting out HostAudienceCacheBound is + // what makes a declaration this replica applied addressable on the very next + // mint; another replica's reaches us within the bound. + s.InvalidateHostAudiences() + return revision, nil +} + +// withdrawDeclaredSolutionRegistration tombstones the registry record a binding +// holds, in the transaction that applies the generation withdrawing it. +// +// The tombstone retains the declaration that withdrew the alias. +// It returns the registry revision the withdrawal produced, or 0 when there was +// nothing to withdraw, so the generation history records the registry state this +// generation actually became. +func (s *Service) withdrawDeclaredSolutionRegistration( + ctx context.Context, record *SolutionHostBindingRecord, + document *solutionhost.SolutionHostBinding, closedTargetID string, now time.Time, +) (int64, error) { + if record.Applied == nil || record.Applied.SolutionID == "" { + // A removal for a binding this host never applied. The generation is + // still recorded, so a later document at a lower generation is refused; + // there is simply no registry record to withdraw. + return 0, nil + } + solutionID := record.Applied.SolutionID + current, err := s.store.GetSolutionRegistrationForUpdate(ctx, solutionID) + if err != nil { + return 0, err + } + if current == nil { + return 0, nil + } + if current.Declared != nil && current.Declared.BindingID != document.Binding { + // Another binding has taken the alias over since. Withdrawing it here + // would remove presence this binding no longer owns. + return 0, nil + } + if current.TombstonedAt != nil { + return 0, nil + } + kind := SolutionDeclaredKind(document.Kind) + if !kind.Valid() { + // Same refusal as the declare path, for the same reason, and reached on + // the same documents: a removal whose kind this host cannot route would + // write a row the CHECK refuses, which surfaces as an opaque constraint + // violation instead of a refusal naming the binding. + return 0, fmt.Errorf("%w: binding %q declares kind %q", + ErrSolutionHostBindingKindNotRoutable, document.Binding, document.Kind) + } + revision, err := s.store.NextSolutionRegistryRevision(ctx) + if err != nil { + return 0, err + } + tombstoned := now + next := &SolutionRegistration{ + SolutionID: current.SolutionID, + Publisher: current.Publisher, + Revision: revision, + UpdatedAt: now, + TombstonedAt: &tombstoned, + Declared: &SolutionDeclaredBinding{ + BindingID: document.Binding, + Generation: document.Generation, + Release: document.Release.Identity(), + // The target the withdrawal closed, kept on the tombstone: the + // record stays attributable to the identity whose consent ended. + TargetID: closedTargetID, + // And the KIND the withdrawn declaration declared. A tombstone is a + // complete declaration, not a partial one: the whole-or-absent CHECK + // admits five columns or none, and `declared_kind` is constrained to + // the two values core admits — so an empty one is refused by the + // database rather than stored. Taken from the document being applied, + // which is the removal's own generation and therefore states the kind + // of the thing being withdrawn. + Kind: kind, + }, + } + if err := s.store.SaveSolutionRegistration(ctx, next); err != nil { + return 0, err + } + // A WITHDRAWAL is the direction that matters: it REMOVES an audience, so a + // stale snapshot would keep a withdrawn solution addressable for up to the + // bound. Invalidated inside the transaction on purpose — a rollback then costs + // one extra read, which is the only direction worth erring in. + s.InvalidateHostAudiences() + if err := s.emitTx(ctx, "solution:"+solutionID, "system", + EventSolutionRegistrationDeleted, "solution", solutionID, "", + map[string]any{ + "solution_id": solutionID, + "publisher": current.Publisher, + "revision": revision, + }); err != nil { + return 0, err + } + return revision, nil +} + +// solutionRegistrationPublisher is the owner of record for a solution id: the +// canonical identity used to attribute its declared presence. +func solutionRegistrationPublisher(solutionID string) string { return "solution:" + solutionID } + +// solutionHostBindingActor and solutionHostBindingResource name a declared +// binding in the audit trail by the solution it is a deployment of when it has +// one, and by its binding ID when it does not — a generation refused before it +// ever addressed a registry record has no solution id to name. +func solutionHostBindingActor(record *SolutionHostBindingRecord) string { + return "solution:" + solutionHostBindingResource(record) +} + +func solutionHostBindingResource(record *SolutionHostBindingRecord) string { + if record.Applied != nil && record.Applied.SolutionID != "" { + return record.Applied.SolutionID + } + return record.BindingID +} + +// reconcileSolutionTarget keeps the installable identity in step with the +// generation being applied, in the same transaction. +// +// A present generation for a binding with no live target OPENS one: this is the +// start of a period an organisation may consent to. A present generation for a +// binding that already has one keeps that identity and only moves its alias — the +// identity surviving a rename is the point, because an installation named it. A +// tombstone CLOSES it, and the row survives: a closed target is the evidence that +// consent ended, and deleting it would make a reused alias indistinguishable from +// a continuous presence. +// +// A tombstone for a binding with no live target is a no-op, like the registry +// withdrawal beside it: there is no period to end. +// +// Closing a target REVOKES every active installation naming it, in this same +// transaction. The target identity makes inheritance inexpressible — a +// replacement binding has its own target, so it cannot be reached through a +// predecessor's installation — but an installation left ACTIVE on a closed +// target is still a record of consent to a presence that has ended, and every +// reader would have to remember to ask about the target's liveness to avoid +// honouring it. Revoking here means only one place knows the rule. +// +// It returns the closed target so the caller can attribute the generation +// history and the audit event to the identity that ended. Reading it back after +// the close would find nothing, which is how the tombstone's history row lost +// its target. +// witnessedTargetClose is the policy log operation whose append landed for the +// close this call is about to perform, and "" when none did. A close with no +// witness is REFUSED: closing the target revokes every installation naming it, +// and performing that with nothing appended is the unwitnessed narrowing the +// protocol forbids outright. +func (s *Service) reconcileSolutionTarget( + ctx context.Context, document *solutionhost.SolutionHostBinding, + solutionID, witnessedTargetClose string, now time.Time, +) (string, error) { + live, err := s.store.GetLiveSolutionTargetForUpdate(ctx, document.Binding) + if err != nil { + return "", err + } + if document.Removed { + if live == nil { + return "", nil + } + if witnessedTargetClose == "" { + return "", fmt.Errorf( + "%w: closing solution target %s was not appended to the policy log, "+ + "so the installations it revokes would be unwitnessed", + ErrPolicyLogUnreachable, live.ID) + } + if err := s.store.CloseSolutionTarget(ctx, live.ID, document.Generation, now); err != nil { + return "", err + } + if err := s.revokeInstallationsOfClosedTarget(ctx, live, document.Generation, now); err != nil { + return "", err + } + return live.ID, nil + } + if live == nil { + opened, err := s.store.OpenSolutionTarget(ctx, document.Binding, solutionID, document.Generation, now) + if err != nil { + return "", err + } + return opened.ID, nil + } + if live.SolutionID == solutionID { + return live.ID, nil + } + if err := s.store.RetargetSolutionTarget(ctx, live.ID, solutionID, now); err != nil { + return "", err + } + return live.ID, nil +} + +// revokeInstallationsOfClosedTarget ends the consent a withdrawn presence was +// granted, and records why. +// +// The reason is stored on the row because a revoked installation that reads like +// an administrator's uninstall is a different fact from one the platform ended: +// the first is somebody's decision and the second is something they must be told +// about, and re-installing is only available for one of them. +// +// Each revocation emits the installation-revoked event with the target it named, +// so the trail says which presence ended rather than only that an installation +// stopped. +func (s *Service) revokeInstallationsOfClosedTarget( + ctx context.Context, target *SolutionTarget, generation uint64, now time.Time, +) error { + reason := fmt.Sprintf( + "solution target %s was withdrawn by generation %d of binding %s", + target.ID, generation, target.BindingID) + revoked, err := s.store.RevokeInstallationsOfTarget(ctx, target.ID, reason, now) + if err != nil { + return err + } + for _, installation := range revoked { + if err := s.emitTx(ctx, "system", "system", EventInstallationRevoked, + "installation", installation.InstallationID, installation.OrgID, + map[string]any{ + "installation_id": installation.InstallationID, + "target_id": target.ID, + "binding_id": target.BindingID, + "generation": int64(generation), + "revoked_reason": reason, + }); err != nil { + return err + } + } + return nil +} + +// ListSolutionTargets returns every installable identity, open and closed. An +// operator reads it to tell a solution that is still the one an organisation +// installed from one that merely reuses its route. +func (s *Service) ListSolutionTargets(ctx context.Context) ([]*SolutionTarget, error) { + var targets []*SolutionTarget + err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + targets, err = s.store.ListSolutionTargets(ctx) + return err + }) + if err != nil { + return nil, fmt.Errorf("list solution targets: %w", err) + } + return targets, nil +} + +// ListSolutionGenerationHistory returns one component's decision trail, newest +// first. It is the Catalogue's history panel and the only record of a refusal the +// next pass superseded. +func (s *Service) ListSolutionGenerationHistory( + ctx context.Context, bindingID string, limit int, +) ([]*SolutionGenerationDecision, error) { + var history []*SolutionGenerationDecision + err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + history, err = s.store.ListSolutionGenerationHistory(ctx, bindingID, limit) + return err + }) + if err != nil { + return nil, fmt.Errorf("list solution generation history: %w", err) + } + return history, nil +} diff --git a/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go b/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go new file mode 100644 index 000000000..41118f646 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_host_bindings_integration_test.go @@ -0,0 +1,1025 @@ +//go:build !pure + +package business_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/codefly-dev/core/solutionhost" + "github.com/google/uuid" + + "accounts/pkg/business" +) + +// The reconciler against Postgres. The row lock, the partial unique index on the +// registry key and the whole-or-absent CHECK constraints only exist there, and +// they are what make two replicas converge and a restart resume. The admission +// rules themselves are decided without a database in +// solution_host_binding_admission_test.go. + +// deliveredSet is a mount that hands over exactly what a test put in it. +type deliveredSet struct { + documents []business.SolutionHostBindingDocument + err error +} + +func (d *deliveredSet) Documents(context.Context) ([]business.SolutionHostBindingDocument, error) { + if d.err != nil { + return nil, d.err + } + return d.documents, nil +} + +func (d *deliveredSet) put(t *testing.T, document *solutionhost.SolutionHostBinding) { + t.Helper() + d.documents = []business.SolutionHostBindingDocument{{ + Source: "mount/" + document.Binding + ".codefly.yaml", + Data: testCarrier(t, document), + }} +} + +// The signer identity this test host attests delivery as. It is a keyless +// certificate SAN in the shape a workflow identity takes, because that is what a +// real BundleVerifier returns and what the host's signer policy is keyed by. +const testSignerIdentity = "https://signer.example/acme-release@refs/heads/main" + +// testBundleVerifier stands in for sigstore-go. A real bundle needs a trust +// root, a transparency-log entry and a certificate that expires, so what is +// exercised here is the host's ORDERING — verify, then admit — rather than the +// cryptography, which has its own unit tests at the seam. +type testBundleVerifier struct{ signer string } + +func (v testBundleVerifier) VerifyBundle(context.Context, []byte, json.RawMessage) (string, error) { + return v.signer, nil +} + +// testCarrier wraps a document in the signed carrier delivery ships: the +// canonical bytes verbatim, plus a bundle over them. Admission takes only +// verified values, so a mount that handed over bare documents would be refused — +// which is the behaviour TestSolutionHostBindingRefusesAnUnsignedDocument pins. +func testCarrier(t *testing.T, document *solutionhost.SolutionHostBinding) []byte { + t.Helper() + // CanonicalBytes, not Marshal: the signing input is the canonical JSON, and + // PresenceFromVerified refuses a payload that is not the canonical encoding + // of the document it decodes to. + canonical, err := document.CanonicalBytes() + if err != nil { + t.Fatalf("canonical bytes for %s: %v", document.Binding, err) + } + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: canonical, + Bundle: json.RawMessage(solutionhost.FixtureBundle), + }) + if err != nil { + t.Fatalf("marshal carrier for %s: %v", document.Binding, err) + } + return carrier +} + +const testHostCoordinate = "acme/test/eu-west-1" + +// The ownership domain this test host accepts delivery from. Core refuses a +// document from an unstated domain, which is what stops a writer claiming an +// unseen binding ID under a domain of its own choosing. +const testOwnershipDomain = "acme" + +// declaredBinding builds a present generation for one solution on this host. The +// binding ID is dotted and the alias is a single lowercase segment, which is the +// shape the renderer commits to (codefly-dev/cli#853): the binding ID is never +// the registry key. +func declaredBinding(t *testing.T, solutionID string, generation uint64) *solutionhost.SolutionHostBinding { + t.Helper() + document := &solutionhost.SolutionHostBinding{ + Schema: solutionhost.SchemaPresenceV2, + Kind: solutionhost.KindSolution, + Binding: "acme.test." + solutionID, + Generation: generation, + OwnershipDomain: testOwnershipDomain, + EnvelopeRevision: 1, + Host: solutionhost.HostTarget{Coordinate: testHostCoordinate, Component: "saas-host"}, + // Three digests, three distinct values. Presence v2 requires the release + // digest — a generation that omits it is one no authority document can be + // matched to — and core types the three separately so that comparing a + // rendered digest to an image digest is a compile error. Giving them + // different bytes here is what keeps a test from passing on a mix-up the + // types happen not to catch. + Release: solutionhost.Release{ + Publisher: "acme", Name: solutionID, Version: "1.4.0", + Digest: solutionhost.ReleaseDigest("sha256:" + strings.Repeat("ef", 32)), + }, + Routes: []solutionhost.Route{{Alias: solutionID, Surface: solutionhost.SurfaceBackend}}, + Artifacts: []solutionhost.Artifact{{ + Surface: solutionhost.SurfaceBackend, + Name: "api", + Release: "acme/" + solutionID + "@1.4.0", + Digest: solutionhost.RenderedDigest("sha256:" + strings.Repeat("ab", 32)), + }}, + Workloads: []solutionhost.Workload{{ + Name: "api", + Artifact: "api", + Container: "api", + Image: solutionhost.Image{ + Repository: "registry.example/acme/" + solutionID, + Digest: solutionhost.ImageDigest("sha256:" + strings.Repeat("cd", 32)), + }, + Identity: solutionhost.WorkloadIdentity{ + Audience: "https://test.acme.example/solutions", + Subject: "system:serviceaccount:" + solutionID + ":api", + SPIFFEID: "spiffe://acme.test/ns/" + solutionID + "/sa/api", + }, + // A sidecar that must never be accepted as the authenticating + // container. Core requires this list EXPLICITLY — empty declares + // "there are none", absent is refused — because a plain slice + // cannot tell those apart and a host that cannot tell has to choose + // between refusing every workload and trusting any container that + // asks. Naming one here rather than writing the empty list keeps + // the non-degenerate case in the reconciler's own fixtures. + NonAuthenticating: &[]string{"telemetry-sidecar"}, + }}, + } + if err := document.Validate(); err != nil { + t.Fatalf("test document is not a valid binding: %v", err) + } + return document +} + +func tombstoneOf(t *testing.T, document *solutionhost.SolutionHostBinding, generation uint64) *solutionhost.SolutionHostBinding { + t.Helper() + removed := *document + removed.Generation = generation + removed.Routes = nil + removed.Artifacts = nil + removed.Workloads = nil + removed.Modules = nil + removed.Endpoints = nil + removed.Removed = true + if err := removed.Validate(); err != nil { + t.Fatalf("test tombstone is not valid: %v", err) + } + return &removed +} + +func testDeclaredSolutionID(t *testing.T) string { + t.Helper() + return "dsol" + strings.ReplaceAll(uuid.NewString(), "-", "")[:12] +} + +func newTestReconciler(t *testing.T, source business.SolutionHostBindingSource) *business.SolutionHostBindingReconciler { + t.Helper() + reconciler, err := business.NewSolutionHostBindingReconciler(testService, + business.SolutionHostBindingReconcilerConfig{ + Source: source, + Verifier: testBundleVerifier{signer: testSignerIdentity}, + Coordinate: testHostCoordinate, + Domains: []string{testOwnershipDomain}, + DomainsBySigner: map[string][]string{testSignerIdentity: {testOwnershipDomain}}, + Interval: time.Minute, + }) + if err != nil { + t.Fatalf("reconciler: %v", err) + } + return reconciler +} + +func bindingState(t *testing.T, bindingID string) *business.SolutionHostBindingRecord { + t.Helper() + records, err := testService.ListSolutionHostBindings(testCtx) + if err != nil { + t.Fatalf("list bindings: %v", err) + } + for _, record := range records { + if record.BindingID == bindingID { + return record + } + } + return nil +} + +func registration(t *testing.T, solutionID string) *business.SolutionRegistration { + t.Helper() + records, _, err := testService.ListSolutionRegistrations(testCtx, true) + if err != nil { + t.Fatalf("list registrations: %v", err) + } + for _, record := range records { + if record.SolutionID == solutionID { + return record + } + } + return nil +} + +// A delivered generation becomes a durable registration record, and the record +// says which binding declared it. Nothing has heartbeated, so it is PENDING: that +// is the whole point — declared and not observed is a state an operator can see, +// where before it was indistinguishable from never deployed. +func TestSolutionHostBinding_DeliveredGenerationDeclaresAPendingRegistration(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + document := declaredBinding(t, solutionID, 1) + mount := &deliveredSet{} + mount.put(t, document) + + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("first pass: %v", err) + } + + record := bindingState(t, document.Binding) + if record == nil || record.Applied == nil { + t.Fatalf("binding was not applied: %+v", record) + } + if record.Applied.Generation != 1 || record.Applied.SolutionID != solutionID { + t.Fatalf("applied = %+v, want generation 1 into %q", record.Applied, solutionID) + } + if record.PendingReason != "" { + t.Fatalf("pending reason = %q, want none", record.PendingReason) + } + if record.Applied.Release != "acme/"+solutionID+"@1.4.0" { + t.Fatalf("applied release = %q", record.Applied.Release) + } + + declared := registration(t, solutionID) + if declared == nil || declared.Declared == nil { + t.Fatalf("registration was not declared: %+v", declared) + } + if declared.Declared.BindingID != document.Binding || declared.Declared.Generation != 1 { + t.Fatalf("declaration = %+v", declared.Declared) + } + if got := declared.Status(); got != business.SolutionRegistrationPending { + t.Fatalf("status = %q, want pending: declared presence is not an observation", got) + } +} + +// A second pass over the same mount applies nothing. core answers DecisionCurrent +// because a host re-reads its mount every pass, and a registry revision that moved +// would churn every replica's cache once per interval, forever. +func TestSolutionHostBinding_RereadingTheSameGenerationChangesNothing(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + mount := &deliveredSet{} + mount.put(t, declaredBinding(t, solutionID, 1)) + reconciler := newTestReconciler(t, mount) + + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("first pass: %v", err) + } + first := registration(t, solutionID) + if first == nil { + t.Fatalf("no registration after the first pass: %+v", bindingState(t, "acme.test."+solutionID)) + } + + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("second pass: %v", err) + } + second := registration(t, solutionID) + if second == nil { + // The re-read refused. This is where an applied record core cannot + // judge shows up: the pass withholds the whole set, so the record a + // previous pass wrote is still there but nothing reconciles again. + t.Fatalf("registration vanished on a re-read: %+v", bindingState(t, "acme.test."+solutionID)) + } + + if second.Revision != first.Revision { + t.Fatalf("revision moved on a re-read: %d then %d", first.Revision, second.Revision) + } +} + +// Two replicas read the same mount and both propose the same generation. The +// second to take the row lock finds it applied and writes nothing, so the +// registry revision does not move twice for one generation. +func TestSolutionHostBinding_TwoReplicasConvergeWithoutDoubleApplying(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + document := declaredBinding(t, solutionID, 1) + first, second := &deliveredSet{}, &deliveredSet{} + first.put(t, document) + second.put(t, document) + + if err := newTestReconciler(t, first).RunOnce(testCtx); err != nil { + t.Fatalf("replica one: %v", err) + } + applied := registration(t, solutionID) + + if err := newTestReconciler(t, second).RunOnce(testCtx); err != nil { + t.Fatalf("replica two: %v", err) + } + after := registration(t, solutionID) + + if after.Revision != applied.Revision { + t.Fatalf("the second replica re-applied: revision %d then %d", applied.Revision, after.Revision) + } + record := bindingState(t, document.Binding) + if record.Applied.Generation != 1 { + t.Fatalf("applied generation = %d, want 1", record.Applied.Generation) + } +} + +// A restart resumes from the recorded applied generation instead of deriving it +// again. A fresh reconciler over the same mount is exactly that: nothing in it +// remembers the previous pass. +func TestSolutionHostBinding_RestartResumesFromTheRecordedGeneration(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + document := declaredBinding(t, solutionID, 3) + mount := &deliveredSet{} + mount.put(t, document) + + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("before restart: %v", err) + } + applied := registration(t, solutionID) + + // A brand-new reconciler, as a restarted process has. + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("after restart: %v", err) + } + if after := registration(t, solutionID); after.Revision != applied.Revision { + t.Fatalf("a restart re-applied the generation: revision %d then %d", applied.Revision, after.Revision) + } +} + +// A generation older than the applied one is refused, and — this is the part that +// matters — the applied generation keeps serving. The refusal and its reason are +// recorded as pending state, so an operator sees that delivery is showing +// something stale rather than nothing happening. +func TestSolutionHostBinding_StaleGenerationLeavesTheRunningOneUntouched(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + mount := &deliveredSet{} + mount.put(t, declaredBinding(t, solutionID, 4)) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("apply generation 4: %v", err) + } + applied := registration(t, solutionID) + + stale := declaredBinding(t, solutionID, 3) + mount.put(t, stale) + // The pass reports the refusal to its caller; the loop logs it and carries on. + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("a withheld document must not fail the pass: %v", err) + } + + record := bindingState(t, stale.Binding) + if record.Applied == nil || record.Applied.Generation != 4 { + t.Fatalf("applied = %+v, want generation 4 still running", record.Applied) + } + if record.Desired == nil || record.Desired.Generation != 3 { + t.Fatalf("desired = %+v, want the generation delivery is showing", record.Desired) + } + if record.PendingGeneration() != 3 { + t.Fatalf("pending generation = %d, want 3", record.PendingGeneration()) + } + if !strings.Contains(record.PendingReason, solutionhost.ErrStaleGeneration.Error()) { + t.Fatalf("pending reason = %q, want it to name the stale generation", record.PendingReason) + } + if record.PendingSince == nil { + t.Fatal("a pending reason must carry the instant it appeared") + } + if after := registration(t, solutionID); after.Revision != applied.Revision { + t.Fatalf("a refused document moved the registry: revision %d then %d", applied.Revision, after.Revision) + } +} + +// A newer generation clears the pending reason as it applies, in the same write. +func TestSolutionHostBinding_ANewGenerationClearsThePendingReason(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + mount := &deliveredSet{} + mount.put(t, declaredBinding(t, solutionID, 4)) + reconciler := newTestReconciler(t, mount) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply generation 4: %v", err) + } + mount.put(t, declaredBinding(t, solutionID, 3)) + _ = reconciler.RunOnce(testCtx) + if bindingState(t, "acme.test."+solutionID).PendingReason == "" { + t.Fatal("the stale document should have left a pending reason") + } + + mount.put(t, declaredBinding(t, solutionID, 5)) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply generation 5: %v", err) + } + record := bindingState(t, "acme.test."+solutionID) + if record.PendingReason != "" || record.PendingSince != nil { + t.Fatalf("pending state survived the apply: %q since %v", record.PendingReason, record.PendingSince) + } + if record.Applied.Generation != 5 { + t.Fatalf("applied generation = %d, want 5", record.Applied.Generation) + } +} + +// Removal is a tombstone generation: it withdraws the route and the record +// survives as a tombstone, so the removal is attributable and a late write from an +// older generation has something to collide with. +func TestSolutionHostBinding_RemovalIsATombstoneGeneration(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + present := declaredBinding(t, solutionID, 4) + mount := &deliveredSet{} + mount.put(t, present) + reconciler := newTestReconciler(t, mount) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply the present generation: %v", err) + } + + mount.put(t, tombstoneOf(t, present, 5)) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply the tombstone: %v", err) + } + + record := bindingState(t, present.Binding) + if record.Applied == nil || !record.Applied.Removed || record.Applied.Generation != 5 { + t.Fatalf("applied = %+v, want a tombstone at generation 5", record.Applied) + } + if len(record.Applied.Routes) != 0 { + t.Fatalf("a tombstone holds no route, got %v", record.Applied.Routes) + } + if record.Applied.SolutionID != solutionID { + t.Fatalf("a tombstone keeps the key it withdrew, got %q", record.Applied.SolutionID) + } + + withdrawn := registration(t, solutionID) + if withdrawn == nil || withdrawn.TombstonedAt == nil { + t.Fatalf("the registration was not withdrawn: %+v", withdrawn) + } + if withdrawn.Frontend != nil || withdrawn.Backend != nil { + t.Fatal("a withdrawal clears the endpoints in the write that records it") + } + if withdrawn.Declared == nil || withdrawn.Declared.Generation != 5 { + t.Fatalf("the tombstone must keep its declaration: %+v", withdrawn.Declared) + } +} + +// An unreadable mount is not an empty desired set. Removal is a tombstone +// generation precisely so a volume that failed to mount can never be reconciled +// as "remove every solution". +func TestSolutionHostBinding_AnUnreadableMountRemovesNothing(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + document := declaredBinding(t, solutionID, 1) + mount := &deliveredSet{} + mount.put(t, document) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("declare: %v", err) + } + applied := registration(t, solutionID) + + broken := &deliveredSet{err: errors.New("input/output error")} + if err := newTestReconciler(t, broken).RunOnce(testCtx); err == nil { + t.Fatal("an unreadable mount must be reported, not treated as an empty set") + } + + record := bindingState(t, document.Binding) + if record.Applied == nil || record.Applied.Removed { + t.Fatalf("applied = %+v, want the generation untouched", record.Applied) + } + if after := registration(t, solutionID); after.TombstonedAt != nil || after.Revision != applied.Revision { + t.Fatalf("an unreadable mount changed the registry: %+v", after) + } +} + +// A mount that goes EMPTY removes nothing either. A document disappearing is not +// a removal — that is what the tombstone generation is for — and a delivery tree +// that synced empty would otherwise withdraw every solution on the host. +func TestSolutionHostBinding_AnEmptyMountRemovesNothing(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + document := declaredBinding(t, solutionID, 1) + mount := &deliveredSet{} + mount.put(t, document) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("declare: %v", err) + } + applied := registration(t, solutionID) + + mount.documents = nil + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("empty pass: %v", err) + } + + record := bindingState(t, document.Binding) + if record.Applied == nil || record.Applied.Removed { + t.Fatalf("applied = %+v, want the generation still applied", record.Applied) + } + if after := registration(t, solutionID); after.TombstonedAt != nil || after.Revision != applied.Revision { + t.Fatalf("an empty mount changed the registry: %+v", after) + } +} + +// A binding whose very first generation is refused still becomes visible, with +// its reason and no applied generation. That is the state the registry snapshot +// alone cannot show — there is no registration record at all — and it is why the +// binding listing exists. +func TestSolutionHostBinding_ABindingRefusedAtItsFirstGenerationIsStillVisible(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + unaddressable := declaredBinding(t, solutionID, 1) + unaddressable.Routes[0].Alias = solutionID + ".internal" + unaddressable.Artifacts[0].Surface = solutionhost.SurfaceBackend + unaddressable.Routes[0].Surface = solutionhost.SurfaceBackend + mount := &deliveredSet{} + mount.put(t, unaddressable) + + if err := newTestReconciler(t, mount).RunOnce(testCtx); err == nil { + t.Fatal("an unaddressable route must be reported") + } + + record := bindingState(t, unaddressable.Binding) + if record == nil { + t.Fatal("a refused binding must still be recorded") + } + if record.Applied != nil { + t.Fatalf("applied = %+v, want nothing applied", record.Applied) + } + if record.Desired == nil || record.Desired.Generation != 1 { + t.Fatalf("desired = %+v, want generation 1", record.Desired) + } + if !strings.Contains(record.PendingReason, "addressable") { + t.Fatalf("pending reason = %q, want it to name the route problem", record.PendingReason) + } + if registration(t, solutionID) != nil { + t.Fatal("a refused generation must not create a registration record") + } +} + +// A refusal that stands across passes keeps the instant it started. Every pass +// re-reads the same mount and re-records the same refusal, so if the instant were +// replaced each time, a binding stuck for a day would read as one that started +// seconds ago — which is the opposite of what the field is for. This covers the +// apply-path refusal specifically, because that is the one a real deployment hits: +// an alias this host cannot address is refused when the generation is applied, not +// when it is admitted. +func TestSolutionHostBinding_AStandingRefusalKeepsWhenItStarted(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + unaddressable := declaredBinding(t, solutionID, 1) + unaddressable.Routes[0].Alias = solutionID + ".internal" + mount := &deliveredSet{} + mount.put(t, unaddressable) + reconciler := newTestReconciler(t, mount) + + if err := reconciler.RunOnce(testCtx); err == nil { + t.Fatal("an unaddressable route must be reported") + } + first := bindingState(t, unaddressable.Binding) + if first.PendingSince == nil { + t.Fatal("a refusal must record when it started") + } + + if err := reconciler.RunOnce(testCtx); err == nil { + t.Fatal("the refusal must still stand on the next pass") + } + second := bindingState(t, unaddressable.Binding) + if second.PendingSince == nil || !second.PendingSince.Equal(*first.PendingSince) { + t.Fatalf("pending_since moved from %v to %v across passes; a standing refusal must keep when it started", + first.PendingSince, second.PendingSince) + } + if second.PendingReason != first.PendingReason { + t.Fatalf("the reason changed across passes: %q then %q", first.PendingReason, second.PendingReason) + } + + // And a generation that does apply clears both. + mount.put(t, declaredBinding(t, solutionID, 2)) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("the addressable generation must apply: %v", err) + } + cleared := bindingState(t, unaddressable.Binding) + if cleared.PendingReason != "" || cleared.PendingSince != nil { + t.Fatalf("pending state survived the apply: %q since %v", cleared.PendingReason, cleared.PendingSince) + } +} + +// A route alias handed from one binding to another completes in one pass: the +// release is applied before the claim, so the claimant does not collide with the +// key the previous holder still records. +func TestSolutionHostBinding_AliasHandsOverInOnePass(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + first := declaredBinding(t, solutionID, 1) + first.Binding = "acme.test." + solutionID + ".a" + mount := &deliveredSet{} + mount.put(t, first) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("first holder: %v", err) + } + + second := declaredBinding(t, solutionID, 1) + second.Binding = "acme.test." + solutionID + ".b" + retiring := tombstoneOf(t, first, 2) + firstData := testCarrier(t, retiring) + secondData := testCarrier(t, second) + // Claimant first, which is the order a mount walk over these names produces. + mount.documents = []business.SolutionHostBindingDocument{ + {Source: "mount/b.codefly.yaml", Data: secondData}, + {Source: "mount/a.codefly.yaml", Data: firstData}, + } + + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("hand-over pass: %v", err) + } + + retired := bindingState(t, first.Binding) + if retired.Applied == nil || !retired.Applied.Removed { + t.Fatalf("the previous holder was not withdrawn: %+v", retired.Applied) + } + claimed := bindingState(t, second.Binding) + if claimed == nil || claimed.Applied == nil { + t.Fatalf("the claimant did not apply: %+v", claimed) + } + if claimed.Applied.SolutionID != solutionID { + t.Fatalf("claimant applied into %q, want %q", claimed.Applied.SolutionID, solutionID) + } + live := registration(t, solutionID) + if live.TombstonedAt != nil { + t.Fatal("the hand-over left the record withdrawn") + } + if live.Declared == nil || live.Declared.BindingID != second.Binding { + t.Fatalf("the record is declared by %+v, want the claimant", live.Declared) + } +} + +// The installable identity (the adversarial review's §9). These run against +// Postgres because the partial unique indexes and the row lock are the things +// under test: they are what make "mint a target if the binding has no live one" +// race-free, and what stops two replicas minting two identities for one presence. + +func liveTarget(t *testing.T, bindingID string) *business.SolutionTarget { + t.Helper() + targets, err := testService.ListSolutionTargets(testCtx) + if err != nil { + t.Fatalf("list targets: %v", err) + } + for _, target := range targets { + if target.BindingID == bindingID && target.Live() { + return target + } + } + return nil +} + +func targetHistory(t *testing.T, bindingID string) []*business.SolutionTarget { + t.Helper() + targets, err := testService.ListSolutionTargets(testCtx) + if err != nil { + t.Fatalf("list targets: %v", err) + } + var history []*business.SolutionTarget + for _, target := range targets { + if target.BindingID == bindingID { + history = append(history, target) + } + } + return history +} + +// A present generation mints the identity an organisation can install. +func TestSolutionTarget_APresentGenerationOpensAnInstallableIdentity(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + document := declaredBinding(t, solutionID, 1) + mount := &deliveredSet{} + mount.put(t, document) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("apply: %v", err) + } + + target := liveTarget(t, document.Binding) + if target == nil { + t.Fatal("a present generation must open a target") + } + if target.SolutionID != solutionID || target.OpenedGeneration != 1 { + t.Fatalf("target = %+v, want alias %q opened at generation 1", target, solutionID) + } + if target.ID == "" { + t.Fatal("a target must carry an identity of its own") + } +} + +// Renaming the route keeps the identity. An installation named the identity, and +// a rename is not a new thing to install. +func TestSolutionTarget_ARenameKeepsTheIdentityAndMovesTheAlias(t *testing.T) { + first := testDeclaredSolutionID(t) + second := testDeclaredSolutionID(t) + document := declaredBinding(t, first, 1) + mount := &deliveredSet{} + mount.put(t, document) + reconciler := newTestReconciler(t, mount) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply generation 1: %v", err) + } + before := liveTarget(t, document.Binding) + + renamed := declaredBinding(t, first, 2) + renamed.Routes[0].Alias = second + renamed.Artifacts[0].Release = renamed.Release.Identity() + mount.put(t, renamed) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply the rename: %v", err) + } + + after := liveTarget(t, document.Binding) + if after == nil { + t.Fatal("the target must survive a rename") + } + if after.ID != before.ID { + t.Fatalf("identity moved from %q to %q; an installation named the old one", before.ID, after.ID) + } + if after.SolutionID != second { + t.Fatalf("alias = %q, want it moved to %q", after.SolutionID, second) + } + if len(targetHistory(t, document.Binding)) != 1 { + t.Fatalf("a rename minted a second identity: %d targets", len(targetHistory(t, document.Binding))) + } +} + +// A tombstone closes the identity, and the row survives as the evidence that +// consent ended. +func TestSolutionTarget_ATombstoneClosesTheIdentityAndKeepsTheRecord(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + present := declaredBinding(t, solutionID, 4) + mount := &deliveredSet{} + mount.put(t, present) + reconciler := newTestReconciler(t, mount) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply: %v", err) + } + opened := liveTarget(t, present.Binding) + + mount.put(t, tombstoneOf(t, present, 5)) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("withdraw: %v", err) + } + + if liveTarget(t, present.Binding) != nil { + t.Fatal("a tombstone must leave no live identity to install") + } + history := targetHistory(t, present.Binding) + if len(history) != 1 { + t.Fatalf("history = %d targets, want the closed one retained", len(history)) + } + closed := history[0] + if closed.ID != opened.ID { + t.Fatalf("the closed target is not the one that was opened: %q vs %q", closed.ID, opened.ID) + } + if closed.ClosedGeneration == nil || *closed.ClosedGeneration != 5 { + t.Fatalf("closed at %v, want the tombstone generation 5", closed.ClosedGeneration) + } +} + +// THE REVIEW'S §9 FAILURE, as a test: a second binding taking a withdrawn +// binding's route alias must get its OWN identity, so it cannot inherit an +// organisation's installation or its team exposure. +func TestSolutionTarget_AReusedAliasDoesNotInheritTheWithdrawnIdentity(t *testing.T) { + alias := testDeclaredSolutionID(t) + first := declaredBinding(t, alias, 1) + first.Binding = "acme.test." + alias + ".x" + mount := &deliveredSet{} + mount.put(t, first) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("present the first binding: %v", err) + } + inherited := liveTarget(t, first.Binding) + if inherited == nil { + t.Fatal("the first binding must have a target to inherit") + } + + // Withdraw it, which is what ends the consent an organisation gave. + mount.put(t, tombstoneOf(t, first, 2)) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("withdraw the first binding: %v", err) + } + + // A different binding now takes the alias the tombstone released. + second := declaredBinding(t, alias, 1) + second.Binding = "acme.test." + alias + ".y" + mount.put(t, second) + if err := newTestReconciler(t, mount).RunOnce(testCtx); err != nil { + t.Fatalf("present the second binding on the reused alias: %v", err) + } + + claimant := liveTarget(t, second.Binding) + if claimant == nil { + t.Fatal("the second binding must get a target of its own") + } + if claimant.ID == inherited.ID { + t.Fatalf("the reused alias %q handed the withdrawn identity %q to binding %q; "+ + "an installation naming it would have transferred without anybody acting", + alias, inherited.ID, second.Binding) + } + if claimant.SolutionID != alias { + t.Fatalf("claimant alias = %q, want the reused %q", claimant.SolutionID, alias) + } + // And the withdrawn identity stays closed, so nothing resolves through it. + for _, target := range targetHistory(t, first.Binding) { + if target.Live() { + t.Fatalf("the withdrawn binding %q still has a live identity", first.Binding) + } + } +} + +// A tombstone is TERMINAL: re-presenting a withdrawn binding ID is refused, and +// a replacement instance needs a new ID. +// +// This test previously asserted the weaker rule — that re-presenting the same ID +// minted a new identity — and core cd443989 replaced it with a refusal, for the +// reason this PR's own installation-target decision rests on: the binding ID is +// the handle every other system holds (installations, operation bindings, team +// grants), so reusing it makes a replacement indistinguishable from continuity, +// which is the one thing a withdrawal exists to make distinguishable. Minting a +// new identity under the old ID still left every holder of that ID re-attached +// to a different instance. +func TestSolutionTarget_RePresentingAWithdrawnBindingIsRefused(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + present := declaredBinding(t, solutionID, 1) + mount := &deliveredSet{} + mount.put(t, present) + reconciler := newTestReconciler(t, mount) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("apply: %v", err) + } + original := liveTarget(t, present.Binding) + + mount.put(t, tombstoneOf(t, present, 2)) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("withdraw: %v", err) + } + + // The same binding ID, at a higher generation. Delivery is entitled to ship + // it; the host is not entitled to apply it. + again := declaredBinding(t, solutionID, 3) + mount.put(t, again) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("re-present pass: %v", err) + } + + record := bindingState(t, present.Binding) + if record == nil { + t.Fatal("the withdrawn binding lost its record") + } + if !strings.Contains(record.PendingReason, solutionhost.ErrTombstoned.Error()) { + t.Fatalf("pending reason = %q, want it to name the terminal tombstone", record.PendingReason) + } + if record.Applied == nil || !record.Applied.Removed { + t.Fatalf("the withdrawal was undone by a re-presentation: %+v", record.Applied) + } + if revived := liveTarget(t, present.Binding); revived != nil { + t.Fatalf("re-presenting a withdrawn binding opened target %q; a tombstone is terminal", revived.ID) + } + + // And the replacement path: a genuinely new instance has a genuinely new + // binding ID, which opens a target of its own. Without this half the test + // would pin "withdrawal is final" without pinning "replacement is possible", + // and the refusal would be indistinguishable from a dead end. + replacement := declaredBinding(t, solutionID, 1) + replacement.Binding = present.Binding + "r" + mount.put(t, replacement) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("replacement pass: %v", err) + } + revived := liveTarget(t, replacement.Binding) + if revived == nil { + t.Fatal("a replacement under a new binding ID must open a target") + } + if revived.ID == original.ID { + t.Fatalf("the replacement reused the withdrawn identity %q", original.ID) + } + // The withdrawn binding's own history stays closed at one period: the + // refused re-presentation added nothing, and the replacement's period + // belongs to the replacement's ID. + if history := targetHistory(t, present.Binding); len(history) != 1 { + t.Fatalf("withdrawn binding history = %d, want the one closed period", len(history)) + } + if history := targetHistory(t, replacement.Binding); len(history) != 1 { + t.Fatalf("replacement history = %d, want one open period", len(history)) + } +} + +// Re-reading the same generation mints nothing: the pass is a poll, and a target +// per pass would be a new installable identity every thirty seconds. +func TestSolutionTarget_RereadingTheSameGenerationMintsNothing(t *testing.T) { + solutionID := testDeclaredSolutionID(t) + mount := &deliveredSet{} + mount.put(t, declaredBinding(t, solutionID, 1)) + reconciler := newTestReconciler(t, mount) + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("first pass: %v", err) + } + if err := reconciler.RunOnce(testCtx); err != nil { + t.Fatalf("second pass: %v", err) + } + if got := len(targetHistory(t, "acme.test."+solutionID)); got != 1 { + t.Fatalf("history = %d targets after two passes, want 1", got) + } +} + +// testReconcilerAcceptingDomains is newTestReconciler with more than one +// accepted ownership domain, and the signer allowed to speak for all of them. +// +// It exists for exactly one test, and the reason is that the default reconciler +// would make that test pass for the wrong reason: with a single accepted domain, +// a document under a second domain is refused by the host's own Domains list +// before the applied record is ever consulted, so the assertion would hold while +// proving nothing about the rule it names. +func testReconcilerAcceptingDomains( + t *testing.T, source business.SolutionHostBindingSource, domains ...string, +) *business.SolutionHostBindingReconciler { + t.Helper() + reconciler, err := business.NewSolutionHostBindingReconciler(testService, + business.SolutionHostBindingReconcilerConfig{ + Source: source, + Verifier: testBundleVerifier{signer: testSignerIdentity}, + Coordinate: testHostCoordinate, + Domains: domains, + DomainsBySigner: map[string][]string{testSignerIdentity: domains}, + Interval: time.Minute, + }) + if err != nil { + t.Fatalf("reconciler: %v", err) + } + return reconciler +} + +// An applied binding is not taken over by a delivery under a different ownership +// domain, even at a higher generation and from a signer the host lets speak for +// that domain. +// +// This is the takeover case. The host was ONCE the last thing standing in it: +// core's `AdmitRendered` carried no applied state, so a publish folded presence +// generations without the one rule that needs a record — `record.Domain != +// document.OwnershipDomain` — and a presence-only module (no contract, so no +// authority gate either) reached the delivery Job with a domain change only this +// host would refuse. +// +// Core has since replaced it with `AdmitRenderedSets`, which takes each document +// WITH its applied record and returns a refusal rather than only recording one, +// and moved the applied-presence rules into one validator shared by +// `appliedByBinding`, `Activate` and the rendered fold — core found a +// blank-domain record had disabled the domain check on two of those three paths. +// +// So the renderer can now catch it, and this test is still the host's own +// refusal rather than a duplicate of core's: a host must not depend on an +// upstream check it does not run. Two independent refusals of a takeover is the +// intended state, and the one that matters here is the one in this process. +// +// This repository was never exposed to the blank-domain hole core closed: +// migration 20 carries `CHECK (applied_domain <> ”)`, so a record with a blank +// domain cannot exist in this database — ” passes `num_nonnulls`, which is why +// the whole-or-absent group alone was not enough and the non-empty rule is +// named separately. +// +// Everything that could refuse it for a cheaper reason is deliberately removed: +// the generation is HIGHER, so it is not stale; both domains are accepted by the +// host, so Domains does not refuse it; and the signer may speak for both, so +// DomainsBySigner does not either. What is left is the applied record, which is +// the rule under test. +func TestSolutionHostBinding_ADomainChangeDoesNotTakeOverAnAppliedBinding(t *testing.T) { + const otherDomain = "other" + solutionID := testDeclaredSolutionID(t) + mount := &deliveredSet{} + + mount.put(t, declaredBinding(t, solutionID, 4)) + if err := testReconcilerAcceptingDomains(t, mount, testOwnershipDomain, otherDomain).RunOnce(testCtx); err != nil { + t.Fatalf("apply generation 4: %v", err) + } + applied := registration(t, solutionID) + + // The same binding ID, a newer generation, a different owner. + takeover := declaredBinding(t, solutionID, 5) + takeover.OwnershipDomain = otherDomain + mount.put(t, takeover) + if err := testReconcilerAcceptingDomains(t, mount, testOwnershipDomain, otherDomain).RunOnce(testCtx); err != nil { + t.Fatalf("a withheld document must not fail the pass: %v", err) + } + + record := bindingState(t, takeover.Binding) + if record.Applied == nil || record.Applied.Generation != 4 { + t.Fatalf("applied = %+v, want generation 4 still running", record.Applied) + } + if record.Applied.Domain != testOwnershipDomain { + t.Fatalf("applied domain = %q, want %q: the takeover moved ownership", + record.Applied.Domain, testOwnershipDomain) + } + if !strings.Contains(record.PendingReason, solutionhost.ErrWrongDomain.Error()) { + t.Fatalf("pending reason = %q, want it to name the domain conflict", record.PendingReason) + } + // The APPLIED-RECORD rule specifically, by the only wording that is unique + // to it. core answers ErrWrongDomain for two different rules — "is delivered + // under domain X, which host Y does not accept" and "was applied under + // domain X and this document declares Y" — and asserting the sentinel alone + // cannot tell them apart. That is not hypothetical: an earlier version of + // this test asserted the sentinel plus "names both domains", and it passed + // with the second domain removed from the host's accepted list, because + // testHostCoordinate is "acme/test/eu-west-1" and so the host-accepts + // message contains "acme" and "other" as well. It proved nothing about the + // rule it is named for. + if !strings.Contains(record.PendingReason, "was applied under domain") { + t.Fatalf("pending reason = %q, want the APPLIED-RECORD refusal; a host-accepts refusal carries the same sentinel and would pass a weaker assertion", + record.PendingReason) + } + // And that it is actionable: a legitimate rename is indistinguishable from a + // takeover here, so the row must name which domain holds the binding and + // which one asked for it. This is past verification, so it carries core's + // wording rather than the coarse pre-verification reason. + if !strings.Contains(record.PendingReason, testOwnershipDomain) || + !strings.Contains(record.PendingReason, otherDomain) { + t.Fatalf("pending reason = %q, want it to name both %q and %q", + record.PendingReason, testOwnershipDomain, otherDomain) + } + if after := registration(t, solutionID); after.Revision != applied.Revision { + t.Fatalf("a refused takeover moved the registry: revision %d then %d", applied.Revision, after.Revision) + } +} diff --git a/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go b/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go new file mode 100644 index 000000000..9c7902059 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_presence_test_helpers_test.go @@ -0,0 +1,88 @@ +//go:build !pure + +package business_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/stretchr/testify/require" +) + +// Declared presence, for the business tests that install something. +// +// An installation names a solution TARGET, and InstallSolution resolves that +// target through the catalogue — accepted applied presence — before it writes +// anything. So a business test that installs has to declare presence first. +// +// This is the business package's own copy of the infra helper rather than a +// shared one: the two packages have separate test databases and separate +// harnesses (`testStore` is the same type but a different instance), and a +// shared helper would have to be exported from production code to be reachable +// from both. A test-only duplicate is the cheaper of the two, and it is +// deliberately written through the same store methods the reconcile pass uses, +// so neither copy can construct a presence state the reconciler could not. + +// uniqueAlias makes a route alias no other test in this package has declared. +// `solution_targets_live_solution` is unique over the alias of every OPEN target +// and the package shares one database, so a fixed alias would collide between +// tests and fail inside the helper. +func uniqueAlias(label string) string { + return label + "-" + strings.ToLower(strings.ReplaceAll(business.NewIDString(), "-", ""))[:12] +} + +// fixtureDigest is a well-formed digest for a fixture: the schema requires +// `sha256:` plus exactly 64 hex characters, so a readable stand-in like +// "sha256:" is refused by a CHECK constraint rather than quietly +// stored — which is the constraint doing its job and a helper not doing its own. +func fixtureDigest(seed string) string { + sum := sha256.Sum256([]byte(seed)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// declarePresence opens a live target for a fresh alias and records the applied +// generation that makes it ACCEPTED, returning (targetID, alias). +func declarePresence(t *testing.T, label string) (string, string) { + t.Helper() + alias := uniqueAlias(label) + bindingID := "acme.test." + alias + now := time.Now().UTC() + entry := business.SolutionHostBindingGeneration{ + Generation: 1, + Digest: fixtureDigest(bindingID), + Document: "{}", + At: now, + } + var targetID string + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + target, err := testStore.OpenSolutionTarget(ctx, bindingID, alias, 1, now) + if err != nil { + return err + } + targetID = target.ID + return testStore.SaveSolutionHostBinding(ctx, &business.SolutionHostBindingRecord{ + BindingID: bindingID, + HostCoordinate: "acme/local/host", + HostComponent: "saas-starter", + Desired: &entry, + Applied: &business.SolutionHostBindingApplied{ + SolutionHostBindingGeneration: entry, + SolutionID: alias, + Routes: []string{alias}, + // Non-empty deliberately: core refuses an applied record with no + // ownership domain, and '' passes a whole-or-absent constraint + // while being exactly the value it rejects. + Domain: "alpha", + Release: "acme/" + alias + "@1.0.0", + }, + UpdatedAt: now, + }) + })) + return targetID, alias +} diff --git a/module/services/accounts/code/pkg/business/solution_registry.go b/module/services/accounts/code/pkg/business/solution_registry.go index 3cc310030..3e58a957b 100644 --- a/module/services/accounts/code/pkg/business/solution_registry.go +++ b/module/services/accounts/code/pkg/business/solution_registry.go @@ -2,60 +2,36 @@ package business import ( "context" - "encoding/json" "errors" "fmt" - "math/big" - "strconv" "strings" "time" "github.com/google/uuid" ) -// Durable solution registry (issue #534). -// -// A solution registers two halves that arrive as separate requests from -// separate processes: the frontend manifest the host renders, and the upstream -// the gateway proxies to. There is no cross-process transaction available to -// make the pair atomic, so this service does not pretend to one. It stores each -// half as it arrives, under one record and one revision line, and serves the -// registration only once both halves are present, compatible, and live. A -// half-registered solution is a durable PENDING record — never a page whose -// backend does not exist. -// -// Revisions come from a database sequence, so they order writes across records -// as well as within one. Compare-and-swap on that revision is what stops a -// publisher still holding an older view from overwriting newer state; a -// tombstone at a revision the caller does not hold is what stops a retiring -// deployment's last retry from resurrecting a registration an operator removed. +// Solution registrations are projections of reconciled declarations. Delivery +// owns their presence and removal; runtime processes cannot write them. var ( // ErrSolutionRegistrationNotFound is returned when no record exists for the - // solution id — distinct from a tombstone, which is a record. + // solution a caller named. + // + // It survives the deletion of the runtime registration writer because it + // describes a READ, not a write: the Work Context mint looks a solution's + // boundary seed up by the id a verified credential named, and "there is no + // such registration" is one of the answers it must be able to give. See + // mapSolutionBoundaryError. ErrSolutionRegistrationNotFound = errors.New("solution registration not found") - // ErrSolutionRegistrationStale is returned when the caller's - // expected_revision does not match the record's current revision. - ErrSolutionRegistrationStale = errors.New("solution registration revision is stale") - // ErrSolutionRegistrationRevisionRequired is returned when a caller tries to - // change a half that already holds different content without naming the - // revision it believes it is replacing. - ErrSolutionRegistrationRevisionRequired = errors.New("solution registration revision required to replace an existing half") - // ErrSolutionRegistrationTombstoned is returned when a write lands on a - // deregistered record without naming the tombstone's revision. This is the - // refusal a delayed heartbeat from a retired deployment gets. + + // ErrSolutionRegistrationTombstoned is returned when the record exists and + // is withdrawn. + // + // Distinct from NotFound on purpose, and more load-bearing now than it was: + // the cold cutover WITHDRAWS a runtime-registered row rather than deleting + // it, so "exists but is not authorized" is a state the registry holds + // durably and a mint must refuse by name rather than reporting an absence. ErrSolutionRegistrationTombstoned = errors.New("solution registration is tombstoned") - // ErrSolutionPublisherMismatch is returned when a registration names a - // different publisher than the one that owns the solution id. - ErrSolutionPublisherMismatch = errors.New("solution registration is owned by another publisher") - // ErrSolutionRegistrationHalfMissing is returned when a write names neither - // half. - ErrSolutionRegistrationHalfMissing = errors.New("solution registration must carry exactly one half") - // ErrSolutionRegistrationIdentityRequired is returned when a write names no - // solution id or no publisher. Distinct from the half rules: such a request - // is not addressable at all, and reporting it as a half problem sends the - // caller to inspect the wrong field. - ErrSolutionRegistrationIdentityRequired = errors.New("solution registration requires a solution id and publisher") ) // SolutionRegistrationStatus is derived from the record at read time. @@ -64,20 +40,16 @@ type SolutionRegistrationStatus string const ( SolutionRegistrationActive SolutionRegistrationStatus = "active" SolutionRegistrationPending SolutionRegistrationStatus = "pending" - SolutionRegistrationExpired SolutionRegistrationStatus = "expired" SolutionRegistrationIncompatible SolutionRegistrationStatus = "incompatible" SolutionRegistrationTombstoned SolutionRegistrationStatus = "tombstoned" ) -// SolutionFrontendHalf is the stored frontend registration. Manifest is the -// document the frontend validated, stored verbatim. The one part of it read -// here is the audit event types its dashboard graph declares, which the audit -// registry admits when the half is written (solution_audit_events.go). +// SolutionFrontendHalf is a stored frontend observation. The frontend validates +// the manifest and its runtime compatibility when reading the projection. type SolutionFrontendHalf struct { Revision int64 Manifest string ContractVersion string - LeaseExpiresAt time.Time } // SolutionBackendHalf is the stored backend registration. @@ -86,24 +58,73 @@ type SolutionBackendHalf struct { Upstream string ServiceAlias string ContractVersion string - LeaseExpiresAt time.Time +} + +// SolutionDeclaredKind is what a declaration declares the presence of. Core +// requires it on every presence document and refuses any other value +// (solutionhost.Kind), so every admitted declaration has exactly one of these +// two and the host never has to infer it. +// +// There is no third value and no zero value with a meaning. A record whose kind +// is the empty string is a record this host cannot route, and both surfaces +// refuse it by name rather than falling back to the kind that happens to be more +// permissive — the module surface, which carries no per-viewer admission. +type SolutionDeclaredKind string + +const ( + // SolutionDeclaredKindSolution is a composed solution instance, routed at + // /solutions//* behind per-viewer installation admission. + SolutionDeclaredKindSolution SolutionDeclaredKind = "solution" + // SolutionDeclaredKindModule is one module instance, routed at /v1//* + // through the ordinary authenticated pipeline. + SolutionDeclaredKindModule SolutionDeclaredKind = "module" +) + +// Valid reports whether a kind is one this host routes. Checked where a +// declaration becomes a record, so an unknown kind is a refusal naming the +// binding rather than a row no surface will serve. +func (kind SolutionDeclaredKind) Valid() bool { + return kind == SolutionDeclaredKindSolution || kind == SolutionDeclaredKindModule +} + +// SolutionDeclaredBinding is the declaration that produced this record: the +// SolutionHostBinding delivery handed the host, and the generation of it the +// host applied (solution_host_bindings.go, issue #952). +type SolutionDeclaredBinding struct { + BindingID string + Generation uint64 + // Release is publisher/name@version of the applied generation. It is the + // declared release. + Release string + // TargetID is the immutable solution target this declaration opened — the + // identity an installation names, carried here so a consumer asked about a + // route alias can resolve it to a target and compare identities rather than + // strings. + TargetID string + // Kind is what the declaration declared the presence of, copied from the + // applied document. It decides which routing surface serves this record. + Kind SolutionDeclaredKind } // SolutionRegistration is the canonical record for one solution. // -// RuntimeBoundary is the opaque id every Work Context minted for this solution -// is sealed under (issue #1015). The store assigns it when the record is -// created and no path here ever writes it again, which is what makes it the -// host's and not the solution's: see SolutionRuntimeBoundaryStore. +// It carries NO runtime-boundary seed. There used to be a `RuntimeBoundary` +// field holding the stored per-registration random (issue #1015); migration 28 +// drops the column, and the boundary is derived from Declared.BindingID per +// organization instead (SolutionRuntimeBoundary). A field no store can populate +// would read as "this solution has no boundary" at every call site that found it +// empty, which is the opposite of the truth. type SolutionRegistration struct { - SolutionID string - Publisher string - Revision int64 - RuntimeBoundary string - Frontend *SolutionFrontendHalf - Backend *SolutionBackendHalf - UpdatedAt time.Time - TombstonedAt *time.Time + SolutionID string + Publisher string + Revision int64 + Frontend *SolutionFrontendHalf + Backend *SolutionBackendHalf + // Declared is the binding that declared this record, or nil when the record + // has not been reconciled from a declaration. + Declared *SolutionDeclaredBinding + UpdatedAt time.Time + TombstonedAt *time.Time } // SolutionRuntimeBoundarySeedStore reads the runtime-boundary seeds the Work @@ -127,10 +148,22 @@ type SolutionRuntimeBoundarySeedStore interface { // SolutionBoundarySeed is what the mint reads about one registration: the seed // its boundary is derived from, the publisher of record, and whether the half // that mints is currently serving. +// SolutionBoundarySeed is what the mint reads about one declared solution. +// +// BindingID replaced an opaque `Seed` read off the registration row: the +// boundary is derived from the declared presence binding, not from a +// per-registration random. +// +// BackendServing is DELIVERED presence, never a heartbeat or a lease. The +// branch deleted the renewal path, so a row is serving when its applied +// declared generation carries a backend half; MissingBackendHalf says so for +// the refusal, which names the two columns it read rather than saying only +// "not serving". type SolutionBoundarySeed struct { - Seed string - Publisher string - BackendServing bool + BindingID string + Publisher string + BackendServing bool + MissingBackendHalf string } // SolutionRuntimeBoundary derives the boundary a solution's Work Context is @@ -146,14 +179,40 @@ type SolutionBoundarySeed struct { // Rotation is deliberately coarse: the seed is the only input, so replacing it // moves every organization's boundary at once and orphans whatever is still // executing under the old one. SOLUTION_REGISTRATION.md §6 states that cost. -func SolutionRuntimeBoundary(seed, orgID string) (string, error) { - namespace, err := uuid.Parse(seed) - if err != nil { - return "", fmt.Errorf("solution runtime boundary seed is not a UUID: %w", err) +// solutionBoundaryNamespace is the fixed namespace the BINDING ID is hashed +// under to produce a per-binding namespace. It is a constant of this host, not +// a secret: unguessability comes from the org derivation below plus the fact +// that a boundary is never returned on a readable record, not from hiding this. +var solutionBoundaryNamespace = uuid.MustParse("6f1b1f3e-7c4a-5c2b-9e55-1a2b3c4d5e6f") + +// SolutionRuntimeBoundary derives one organization's boundary for a solution +// from the DECLARED PRESENCE BINDING ID. +// +// It used to take the `runtime_boundary` column, a per-registration +// `gen_random_uuid()` default. That was wrong in a way nothing caught: the +// column is per REGISTRATION, so a solution withdrawn and re-registered took a +// fresh random and every run filed under the old boundary was orphaned, while a +// binding that never moved could still have its boundary replaced by a write it +// did not make. The binding id is the identity that survives re-registration +// and is terminal with its tombstone, which is exactly the lifetime a boundary +// must have. +// +// The binding id is NOT a UUID — a binding may carry characters a path segment +// may not — so it is hashed under this host's namespace to get one, and the org +// derivation then runs unchanged. Two steps, because one boundary per solution +// would make every tenant of a solution share one. +// +// Rotation is coarse by construction: the binding id is the only input, so a +// boundary moves exactly when the binding does. SOLUTION_REGISTRATION.md §6 +// states that cost. +func SolutionRuntimeBoundary(bindingID, orgID string) (string, error) { + if strings.TrimSpace(bindingID) == "" { + return "", errors.New("solution runtime boundary needs a declared binding id") } if orgID == "" { return "", errors.New("solution runtime boundary needs an organization") } + namespace := uuid.NewSHA1(solutionBoundaryNamespace, []byte(bindingID)) return uuid.NewSHA1(namespace, []byte(orgID)).String(), nil } @@ -199,30 +258,35 @@ func IsSolutionRuntimeBoundary(seeds []string, candidate, orgID string) bool { return false } -// Status resolves the record against the wall clock. +// Status reports declared availability, independent of a runtime clock. // -// The order is deliberate. A tombstone outranks everything: the record was -// removed, and nothing about its former endpoints is interesting. A contract -// mismatch outranks an expired lease because it is a property of the record -// itself — renewing the lease would not fix it — whereas expiry is a transient -// liveness fact that the publisher's next heartbeat clears. +// WHICH HALVES ARE REQUIRED DEPENDS ON THE KIND, and that is not a relaxation. +// A solution serves a page and a backend, so both halves and agreeing contract +// versions are what make it usable. A module has no browser remote at all: it is +// reached at /v1//*, nothing loads a manifest for it, and demanding a +// frontend half would leave every module PENDING for ever — serving traffic the +// gateway refuses while the registry reports it as waiting for a deployment that +// is not coming. The incompatible case needs two contract versions to disagree, +// so it cannot arise for a module either. // -// Expiry is evaluated over whichever halves are present, and outranks pending. -// A half that registered once and then stopped renewing is dead, not waiting: -// reporting it as pending sends an operator hunting the deployment that never -// arrived, when the one that did arrive is the thing that stopped. -func (r *SolutionRegistration) Status(now time.Time) SolutionRegistrationStatus { - switch { - case r.TombstonedAt != nil: +// A record with no declaration keeps the solution reading. It is the stricter of +// the two, and an undeclared record is withdrawn by the cutover's own constraint +// anyway, so this is the fail-closed direction. +func (r *SolutionRegistration) Status() SolutionRegistrationStatus { + if r.TombstonedAt != nil { return SolutionRegistrationTombstoned + } + if r.Declared != nil && r.Declared.Kind == SolutionDeclaredKindModule { + if r.Backend == nil { + return SolutionRegistrationPending + } + return SolutionRegistrationActive + } + switch { case r.Frontend != nil && r.Backend != nil && r.Frontend.ContractVersion != "" && r.Backend.ContractVersion != "" && r.Frontend.ContractVersion != r.Backend.ContractVersion: return SolutionRegistrationIncompatible - case r.Frontend != nil && !r.Frontend.LeaseExpiresAt.After(now): - return SolutionRegistrationExpired - case r.Backend != nil && !r.Backend.LeaseExpiresAt.After(now): - return SolutionRegistrationExpired case r.Frontend == nil || r.Backend == nil: return SolutionRegistrationPending default: @@ -230,433 +294,6 @@ func (r *SolutionRegistration) Status(now time.Time) SolutionRegistrationStatus } } -// SolutionFrontendRegistration is the caller-supplied frontend half. -type SolutionFrontendRegistration struct { - Manifest string - ContractVersion string -} - -// SolutionBackendRegistration is the caller-supplied backend half. -type SolutionBackendRegistration struct { - Upstream string - ServiceAlias string - ContractVersion string -} - -// SolutionRegistrationWrite is one half-write. Exactly one of Frontend and -// Backend is set; ExpectedRevision carries the compare-and-swap token when the -// caller holds one. -type SolutionRegistrationWrite struct { - SolutionID string - Publisher string - ExpectedRevision *int64 - Lease time.Duration - Frontend *SolutionFrontendRegistration - Backend *SolutionBackendRegistration -} - -// PutSolutionRegistration writes or renews one half of one registration. -// -// A write whose content matches what is stored is a lease renewal: it refreshes -// liveness and deliberately leaves the revision alone, so a heartbeat is -// invisible to a consumer comparing snapshots and a heartbeat storm cannot -// churn every replica's cache. Claiming a half the record does not yet hold -// needs no token. Replacing a half that holds different content does, and it -// must be the record's current revision. -func (s *Service) PutSolutionRegistration(ctx context.Context, write SolutionRegistrationWrite) (*SolutionRegistration, error) { - if (write.Frontend == nil) == (write.Backend == nil) { - return nil, ErrSolutionRegistrationHalfMissing - } - if write.SolutionID == "" || write.Publisher == "" { - return nil, ErrSolutionRegistrationIdentityRequired - } - - var result *SolutionRegistration - err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - now := time.Now().UTC() - current, err := s.store.GetSolutionRegistrationForUpdate(ctx, write.SolutionID) - if err != nil { - return err - } - next, changed, err := planSolutionRegistrationWrite(current, write, now) - if err != nil { - return err - } - // The manifest's dashboard graph is also the solution's declaration of - // the audit event types it owns (solution_audit_events.go). They are - // admitted in this transaction, so a declaration the audit registry - // refuses refuses the whole write and the last admitted registration - // keeps serving. A renewal carries a manifest already admitted, and is - // deliberately not re-read: a rule tightened by a later release must - // not stop a working registration from renewing. - var takenOver []string - if changed && write.Frontend != nil { - declared, err := ParseDeclaredAuditEventTypes(write.SolutionID, write.Frontend.Manifest) - if err != nil { - return err - } - if takenOver, err = s.admitDeclaredAuditEventTypes(ctx, write.SolutionID, declared); err != nil { - return err - } - } - if changed { - revision, err := s.store.NextSolutionRegistryRevision(ctx) - if err != nil { - return err - } - next.Revision = revision - if write.Frontend != nil { - next.Frontend.Revision = revision - } else { - next.Backend.Revision = revision - } - } - if err := s.store.SaveSolutionRegistration(ctx, next); err != nil { - return err - } - // A renewal is not an event: it says the publisher is still alive, which - // the lease already records. Auditing only real changes keeps the trail - // readable at heartbeat cadence. - if changed { - half := "backend" - if write.Frontend != nil { - half = "frontend" - } - payload := map[string]any{ - "solution_id": write.SolutionID, - "publisher": write.Publisher, - "half": half, - "revision": next.Revision, - } - // A takeover moves every declared type in a namespace from the - // solution the operator unbound to this one, so it is recorded with - // the write that performed it. - if len(takenOver) > 0 { - payload["audit_namespaces_taken_over"] = takenOver - } - if err := s.emitTx(ctx, "solution:"+write.SolutionID, "system", - EventSolutionRegistrationUpdated, "solution", write.SolutionID, "", payload); err != nil { - return err - } - } - result = next - return nil - }) - if err != nil { - return nil, err - } - return result, nil -} - -// planSolutionRegistrationWrite computes the record a write produces, and -// whether it advanced the registration (as opposed to renewing a lease). It is -// pure so the compare-and-swap rules can be exercised without a database. -func planSolutionRegistrationWrite( - current *SolutionRegistration, write SolutionRegistrationWrite, now time.Time, -) (*SolutionRegistration, bool, error) { - leaseUntil := now.Add(write.Lease) - - if current == nil { - // Nothing to compare against: a caller presenting a token is working - // from a view of a record that no longer exists. - if write.ExpectedRevision != nil { - return nil, false, ErrSolutionRegistrationStale - } - // RuntimeBoundary is deliberately left empty: the store assigns it on - // the INSERT this write becomes and reports back what it assigned, so - // nothing above the database — including this planner — is ever in a - // position to choose one. - next := &SolutionRegistration{ - SolutionID: write.SolutionID, - Publisher: write.Publisher, - UpdatedAt: now, - } - applySolutionHalf(next, write, leaseUntil) - return next, true, nil - } - - if current.Publisher != write.Publisher { - return nil, false, ErrSolutionPublisherMismatch - } - if write.ExpectedRevision != nil && *write.ExpectedRevision != current.Revision { - return nil, false, ErrSolutionRegistrationStale - } - - if current.TombstonedAt != nil { - // Re-registering a removed solution is allowed, but only for a caller - // that has seen the tombstone and named its revision. A retry still - // carrying the pre-deletion view — or none at all — is refused. - if write.ExpectedRevision == nil { - return nil, false, ErrSolutionRegistrationTombstoned - } - // The boundary is carried across the tombstone, not re-drawn: the - // record is the same solution under the same publisher, and the runs it - // already admitted stay the ones it can read. - next := &SolutionRegistration{ - SolutionID: current.SolutionID, - Publisher: current.Publisher, - RuntimeBoundary: current.RuntimeBoundary, - UpdatedAt: now, - } - applySolutionHalf(next, write, leaseUntil) - return next, true, nil - } - - next := *current - next.UpdatedAt = now - if unchangedSolutionHalf(current, write) { - renewSolutionHalf(&next, write, leaseUntil) - return &next, false, nil - } - if solutionHalfPresent(current, write) && write.ExpectedRevision == nil { - return nil, false, ErrSolutionRegistrationRevisionRequired - } - applySolutionHalf(&next, write, leaseUntil) - return &next, true, nil -} - -func solutionHalfPresent(record *SolutionRegistration, write SolutionRegistrationWrite) bool { - if write.Frontend != nil { - return record.Frontend != nil - } - return record.Backend != nil -} - -func unchangedSolutionHalf(record *SolutionRegistration, write SolutionRegistrationWrite) bool { - if write.Frontend != nil { - return record.Frontend != nil && - sameManifest(record.Frontend.Manifest, write.Frontend.Manifest) && - record.Frontend.ContractVersion == write.Frontend.ContractVersion - } - return record.Backend != nil && - record.Backend.Upstream == write.Backend.Upstream && - record.Backend.ServiceAlias == write.Backend.ServiceAlias && - record.Backend.ContractVersion == write.Backend.ContractVersion -} - -// sameManifest reports whether a stored frontend manifest and a re-sent one are -// the same JSON value. -// -// Comparing the bytes cannot tell: the registry stores the manifest as jsonb, -// which Postgres re-serializes on read — keys reordered, a space after every -// colon — so the stored text never equals the compact text the host sends. -// Compared byte for byte, every heartbeat of an unchanged solution read as a -// change: a new registry revision, a solution.registration_updated audit event -// and a registry cache invalidation on every replica, once per beat, forever. -// Two texts are the same manifest when they decode to equal values. Text that -// does not decode is compared as bytes, as before. -func sameManifest(stored, sent string) bool { - if stored == sent { - return true - } - storedValue, storedErr := decodeManifest(stored) - sentValue, sentErr := decodeManifest(sent) - if storedErr != nil || sentErr != nil { - return false - } - return sameJSONValue(storedValue, sentValue) -} - -func decodeManifest(text string) (any, error) { - decoder := json.NewDecoder(strings.NewReader(text)) - decoder.UseNumber() - var value any - if err := decoder.Decode(&value); err != nil { - return nil, err - } - if decoder.More() { - return nil, errors.New("trailing data after the manifest") - } - return value, nil -} - -// sameJSONValue compares two decoded manifests the way the column they live in -// does. reflect.DeepEqual cannot: json.Number holds the literal as written, so -// it compares spellings, and jsonb stores numbers as numeric — which never -// renders an exponent. A manifest carrying 1e-7 comes back as 0.0000001 and -// every heartbeat of it read as a change, which is the whole bug this function -// exists to stop, surviving for exactly the manifests whose numbers Postgres -// rewrites. -func sameJSONValue(stored, sent any) bool { - switch left := stored.(type) { - case map[string]any: - right, ok := sent.(map[string]any) - if !ok || len(left) != len(right) { - return false - } - for key, value := range left { - other, present := right[key] - if !present || !sameJSONValue(value, other) { - return false - } - } - return true - case []any: - right, ok := sent.([]any) - if !ok || len(left) != len(right) { - return false - } - for index := range left { - if !sameJSONValue(left[index], right[index]) { - return false - } - } - return true - case json.Number: - right, ok := sent.(json.Number) - return ok && sameJSONNumber(left, right) - case string: - right, ok := sent.(string) - return ok && left == right - case bool: - right, ok := sent.(bool) - return ok && left == right - case nil: - return sent == nil - default: - return false - } -} - -// maxComparableNumberExponent bounds the exponent a number literal may carry -// before it is compared as text instead. A rational built from 1e999999999 -// would be materialised digit by digit, and the literal is caller-supplied: -// the write that carries it is refused by the numeric column anyway, so -// falling back to a text comparison costs a renewal that was never going to -// land and spends no memory reaching that answer. -const maxComparableNumberExponent = 10000 - -// maxComparableNumberDigits bounds the mantissa for the same reason. -const maxComparableNumberDigits = 4096 - -// sameJSONNumber compares two JSON number literals by value, which is what -// jsonb's own equality does once they are numerics. big.Rat is exact over -// every JSON number — they are all finite decimals — so no change is lost to -// float rounding. -func sameJSONNumber(stored, sent json.Number) bool { - if stored == sent { - return true - } - if !comparableNumber(stored) || !comparableNumber(sent) { - return false - } - left, leftOK := new(big.Rat).SetString(string(stored)) - right, rightOK := new(big.Rat).SetString(string(sent)) - if !leftOK || !rightOK { - return false - } - return left.Cmp(right) == 0 -} - -func comparableNumber(literal json.Number) bool { - text := string(literal) - if len(text) > maxComparableNumberDigits { - return false - } - exponent := strings.IndexAny(text, "eE") - if exponent < 0 { - return true - } - magnitude, err := strconv.Atoi(strings.TrimPrefix(text[exponent+1:], "+")) - if err != nil { - return false - } - if magnitude < 0 { - magnitude = -magnitude - } - return magnitude <= maxComparableNumberExponent -} - -func renewSolutionHalf(record *SolutionRegistration, write SolutionRegistrationWrite, leaseUntil time.Time) { - if write.Frontend != nil { - renewed := *record.Frontend - renewed.LeaseExpiresAt = leaseUntil - record.Frontend = &renewed - return - } - renewed := *record.Backend - renewed.LeaseExpiresAt = leaseUntil - record.Backend = &renewed -} - -func applySolutionHalf(record *SolutionRegistration, write SolutionRegistrationWrite, leaseUntil time.Time) { - if write.Frontend != nil { - record.Frontend = &SolutionFrontendHalf{ - Manifest: write.Frontend.Manifest, - ContractVersion: write.Frontend.ContractVersion, - LeaseExpiresAt: leaseUntil, - } - return - } - record.Backend = &SolutionBackendHalf{ - Upstream: write.Backend.Upstream, - ServiceAlias: write.Backend.ServiceAlias, - ContractVersion: write.Backend.ContractVersion, - LeaseExpiresAt: leaseUntil, - } -} - -// DeleteSolutionRegistration deregisters a solution. The record survives as a -// tombstone with both halves cleared: routing and page availability go away in -// the same write that records the removal, and the tombstone is what a later -// heartbeat collides with instead of recreating the registration. -// -// Deleting an already-tombstoned record is a no-op that returns the tombstone, -// so a retried deregistration is safe. -func (s *Service) DeleteSolutionRegistration( - ctx context.Context, solutionID string, expectedRevision *int64, -) (*SolutionRegistration, error) { - var result *SolutionRegistration - err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { - now := time.Now().UTC() - current, err := s.store.GetSolutionRegistrationForUpdate(ctx, solutionID) - if err != nil { - return err - } - if current == nil { - return ErrSolutionRegistrationNotFound - } - if expectedRevision != nil && *expectedRevision != current.Revision { - return ErrSolutionRegistrationStale - } - if current.TombstonedAt != nil { - result = current - return nil - } - revision, err := s.store.NextSolutionRegistryRevision(ctx) - if err != nil { - return err - } - tombstoned := now - next := &SolutionRegistration{ - SolutionID: current.SolutionID, - Publisher: current.Publisher, - Revision: revision, - RuntimeBoundary: current.RuntimeBoundary, - UpdatedAt: now, - TombstonedAt: &tombstoned, - } - if err := s.store.SaveSolutionRegistration(ctx, next); err != nil { - return err - } - if err := s.emitTx(ctx, "solution:"+solutionID, "system", - EventSolutionRegistrationDeleted, "solution", solutionID, "", - map[string]any{ - "solution_id": solutionID, - "publisher": current.Publisher, - "revision": revision, - }); err != nil { - return err - } - result = next - return nil - }) - if err != nil { - return nil, err - } - return result, nil -} - // ListSolutionRegistrations returns the registry snapshot a consumer rebuilds // its cache from, plus the highest revision in it: two consumers reporting the // same registry revision have converged. diff --git a/module/services/accounts/code/pkg/business/solution_registry_cutover_test.go b/module/services/accounts/code/pkg/business/solution_registry_cutover_test.go new file mode 100644 index 000000000..50214c9a1 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_registry_cutover_test.go @@ -0,0 +1,24 @@ +package business + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSolutionRegistrationDoesNotExpireWithoutHeartbeat(t *testing.T) { + record := &SolutionRegistration{ + Declared: &SolutionDeclaredBinding{BindingID: "acme.test.example", Generation: 1, TargetID: "example-target"}, + Frontend: &SolutionFrontendHalf{Manifest: `{"id":"example"}`, ContractVersion: "v1"}, + Backend: &SolutionBackendHalf{Upstream: "http://example.svc", ServiceAlias: "example", ContractVersion: "v1"}, + } + require.Equal(t, SolutionRegistrationActive, record.Status()) + record.Backend.ContractVersion = "v2" + require.Equal(t, SolutionRegistrationIncompatible, record.Status()) + record.Backend = nil + require.Equal(t, SolutionRegistrationPending, record.Status()) + now := time.Now() + record.TombstonedAt = &now + require.Equal(t, SolutionRegistrationTombstoned, record.Status()) +} diff --git a/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go b/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go new file mode 100644 index 000000000..76db35ef3 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_registry_declaration_test.go @@ -0,0 +1,202 @@ +package business + +import ( + "context" + "testing" + "time" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +type declarationProjectionStore struct { + Store + current *SolutionRegistration + saved *SolutionRegistration +} + +func (s *declarationProjectionStore) GetSolutionRegistrationForUpdate(context.Context, string) (*SolutionRegistration, error) { + return s.current, nil +} +func (s *declarationProjectionStore) NextSolutionRegistryRevision(context.Context) (int64, error) { + return 10, nil +} +func (s *declarationProjectionStore) SaveSolutionRegistration(_ context.Context, record *SolutionRegistration) error { + s.saved = record + return nil +} + +func TestDeclaredPresenceDoesNotAdoptRuntimeObservations(t *testing.T) { + for _, tc := range []struct { + name string + declared *SolutionDeclaredBinding + keep bool + }{ + {"runtime", nil, false}, + {"replaced target", &SolutionDeclaredBinding{BindingID: "acme.test.example", TargetID: "old-target"}, false}, + {"same target", &SolutionDeclaredBinding{BindingID: "acme.test.example", TargetID: "current-target"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + store := &declarationProjectionStore{current: &SolutionRegistration{ + SolutionID: "example", Publisher: "solution:example", Declared: tc.declared, + Frontend: &SolutionFrontendHalf{Manifest: "{}"}, Backend: &SolutionBackendHalf{Upstream: "http://example.svc"}, + }} + svc := &Service{store: store} + document := &solutionhost.SolutionHostBinding{Binding: "acme.test.example", Generation: 2, + Kind: solutionhost.KindSolution, + Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}} + _, err := svc.declareSolutionRegistration(context.Background(), document, "example", "current-target", time.Now()) + require.NoError(t, err) + require.NotNil(t, store.saved) + require.Equal(t, tc.keep, store.saved.Frontend != nil) + require.Equal(t, tc.keep, store.saved.Backend != nil) + require.Equal(t, "current-target", store.saved.Declared.TargetID) + }) + } +} + +// The KIND the document declared reaches the registry record, and it is the +// document's own value rather than a default (issue #952). +// +// The host used to drop it, which left the registry unable to say whether a +// record was a solution or a module — and the two are routed on surfaces whose +// admission differs, so a gateway that could not tell them apart had to serve +// both from one surface or serve neither. +// +// Asserted for BOTH kinds in one table, because a check that only ever saw +// "solution" would pass against a writer that hardcoded it. +func TestTheDeclaredKindReachesTheRegistryRecord(t *testing.T) { + for _, tc := range []struct { + kind solutionhost.Kind + want SolutionDeclaredKind + }{ + {solutionhost.KindSolution, SolutionDeclaredKindSolution}, + {solutionhost.KindModule, SolutionDeclaredKindModule}, + } { + t.Run(string(tc.kind), func(t *testing.T) { + store := &declarationProjectionStore{} + svc := &Service{store: store} + document := &solutionhost.SolutionHostBinding{ + Binding: "acme.test.example", Generation: 2, Kind: tc.kind, + Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, + } + _, err := svc.declareSolutionRegistration( + context.Background(), document, "example", "current-target", time.Now()) + require.NoError(t, err) + require.NotNil(t, store.saved) + require.Equal(t, tc.want, store.saved.Declared.Kind) + }) + } +} + +// A kind this host has no routing surface for is REFUSED, and the refusal leaves +// the record alone. +// +// Core admits only its own two values, so this is reachable only from a core whose +// vocabulary grew past this host's. Writing the record anyway would produce a row +// no surface serves and no operator can explain; refusing names the binding and +// leaves whatever generation was running in place. +// +// The empty kind is in the table deliberately: it is what a document carries when +// a caller built it by hand, and it must not pass as "the default kind". +func TestADeclaredKindThisHostDoesNotRouteIsRefused(t *testing.T) { + for _, kind := range []solutionhost.Kind{"", "gadget", "SOLUTION"} { + t.Run(string("kind="+kind), func(t *testing.T) { + store := &declarationProjectionStore{} + svc := &Service{store: store} + document := &solutionhost.SolutionHostBinding{ + Binding: "acme.test.example", Generation: 2, Kind: kind, + Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, + } + _, err := svc.declareSolutionRegistration( + context.Background(), document, "example", "current-target", time.Now()) + require.ErrorIs(t, err, ErrSolutionHostBindingKindNotRoutable) + require.Contains(t, err.Error(), "acme.test.example", + "the refusal must name the binding an operator has to go and look at") + require.Nil(t, store.saved, "nothing may be written for a kind no surface serves") + }) + } +} + +// countingDeclarationStore is declarationProjectionStore plus the audience +// vocabulary's read, counted. +type countingDeclarationStore struct { + declarationProjectionStore + reads int +} + +func (s *countingDeclarationStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *countingDeclarationStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + s.reads++ + return nil, nil +} + +// THE APPLY PATH INVALIDATES THE VOCABULARY SNAPSHOT — asserted by driving the +// apply, not by calling the invalidator. +// +// The first version of this test called Service.InvalidateHostAudiences() itself +// and passed with the invalidation deleted from both apply paths: it proved the +// mechanism worked and nothing about whether the reconciler used it. A fake for the +// thing under test proves nothing about the thing under test. +// +// Both directions, because they are different facts and only one of them is +// dangerous: a declaration ADDS an audience (a stale snapshot merely delays a new +// solution becoming addressable), and a withdrawal REMOVES one (a stale snapshot +// keeps a withdrawn solution addressable for up to the bound). +func TestTheApplyPathInvalidatesTheAudienceSnapshot(t *testing.T) { + document := func(kind solutionhost.Kind) *solutionhost.SolutionHostBinding { + return &solutionhost.SolutionHostBinding{ + Binding: "acme.test.example", Generation: 2, Kind: kind, + Release: solutionhost.Release{Publisher: "acme", Name: "example", Version: "1.0.0"}, + } + } + + t.Run("declaring invalidates", func(t *testing.T) { + store := &countingDeclarationStore{} + svc := &Service{store: store} + _, err := svc.HostAudiences(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, store.reads) + _, err = svc.HostAudiences(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, store.reads, "the snapshot serves inside the bound") + + _, err = svc.declareSolutionRegistration( + context.Background(), document(solutionhost.KindSolution), "example", "target", time.Now()) + require.NoError(t, err) + + _, err = svc.HostAudiences(context.Background()) + require.NoError(t, err) + require.Equal(t, 2, store.reads, "applying a declaration must drop the snapshot") + }) + + t.Run("withdrawing invalidates", func(t *testing.T) { + store := &countingDeclarationStore{} + store.current = &SolutionRegistration{ + SolutionID: "example", Publisher: "solution:example", + Declared: &SolutionDeclaredBinding{BindingID: "acme.test.example", TargetID: "target", Kind: SolutionDeclaredKindSolution}, + } + // emitTx returns nil when no audit emitter is wired, so the withdrawal's own + // audit write is not what this test is about. + svc := &Service{store: store} + _, err := svc.HostAudiences(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, store.reads) + + record := &SolutionHostBindingRecord{ + BindingID: "acme.test.example", + Applied: &SolutionHostBindingApplied{SolutionID: "example"}, + } + _, err = svc.withdrawDeclaredSolutionRegistration( + context.Background(), record, document(solutionhost.KindSolution), "target", time.Now()) + require.NoError(t, err) + + _, err = svc.HostAudiences(context.Background()) + require.NoError(t, err) + require.Equal(t, 2, store.reads, + "applying a withdrawal must drop the snapshot: a stale one keeps a withdrawn solution addressable") + }) +} diff --git a/module/services/accounts/code/pkg/business/solution_registry_integration_test.go b/module/services/accounts/code/pkg/business/solution_registry_integration_test.go deleted file mode 100644 index 052d7ff17..000000000 --- a/module/services/accounts/code/pkg/business/solution_registry_integration_test.go +++ /dev/null @@ -1,438 +0,0 @@ -//go:build !pure - -package business_test - -import ( - "strings" - "testing" - "time" - - "github.com/google/uuid" - - "accounts/pkg/business" -) - -// These exercise the registry against Postgres: the revision sequence, the row -// lock, and the half-whole/tombstone CHECK constraints only exist there. The -// pure compare-and-swap rules are covered in solution_registry_test.go. - -func testSolutionID(t *testing.T) string { - t.Helper() - return "sol" + strings.ReplaceAll(uuid.NewString(), "-", "")[:16] -} - -func frontendWrite(id, publisher, manifest string) business.SolutionRegistrationWrite { - return business.SolutionRegistrationWrite{ - SolutionID: id, - Publisher: publisher, - Lease: time.Minute, - Frontend: &business.SolutionFrontendRegistration{Manifest: manifest}, - } -} - -func backendWrite(id, publisher, upstream string) business.SolutionRegistrationWrite { - return business.SolutionRegistrationWrite{ - SolutionID: id, - Publisher: publisher, - Lease: time.Minute, - Backend: &business.SolutionBackendRegistration{ - Upstream: upstream, - ServiceAlias: id, - }, - } -} - -func TestSolutionRegistry_HalvesConvergeOnOneRecord(t *testing.T) { - id := testSolutionID(t) - - record, err := testService.PutSolutionRegistration(testCtx, frontendWrite(id, "acme", `{"id":"`+id+`"}`)) - if err != nil { - t.Fatalf("frontend half: %v", err) - } - if got := record.Status(time.Now().UTC()); got != business.SolutionRegistrationPending { - t.Fatalf("status after one half = %q, want pending", got) - } - frontendRevision := record.Revision - - record, err = testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("backend half: %v", err) - } - if got := record.Status(time.Now().UTC()); got != business.SolutionRegistrationActive { - t.Fatalf("status after both halves = %q, want active", got) - } - if record.Revision <= frontendRevision { - t.Fatalf("revision did not advance: %d then %d", frontendRevision, record.Revision) - } - if record.Frontend == nil || record.Backend == nil { - t.Fatal("both halves must survive on one record") - } -} - -// Restart recovery and multi-replica convergence both reduce to this: the -// snapshot a consumer rebuilds from is authoritative and carries a revision. -func TestSolutionRegistry_SnapshotCarriesRegistryRevision(t *testing.T) { - id := testSolutionID(t) - if _, err := testService.PutSolutionRegistration(testCtx, frontendWrite(id, "acme", `{"id":"`+id+`"}`)); err != nil { - t.Fatalf("frontend half: %v", err) - } - written, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("backend half: %v", err) - } - - records, revision, err := testService.ListSolutionRegistrations(testCtx, false) - if err != nil { - t.Fatalf("list: %v", err) - } - if revision < written.Revision { - t.Fatalf("registry revision %d is behind the record's %d", revision, written.Revision) - } - var found *business.SolutionRegistration - for _, candidate := range records { - if candidate.SolutionID == id { - found = candidate - } - } - if found == nil { - t.Fatal("registration missing from the snapshot") - } - if found.Backend == nil || found.Backend.Upstream != "http://upstream.example:8080" { - t.Fatalf("backend half did not round-trip: %+v", found.Backend) - } -} - -func TestSolutionRegistry_RenewalKeepsRevision(t *testing.T) { - id := testSolutionID(t) - first, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("first: %v", err) - } - renewed, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("renewal: %v", err) - } - if renewed.Revision != first.Revision { - t.Fatalf("renewal advanced the revision %d -> %d", first.Revision, renewed.Revision) - } - if !renewed.Backend.LeaseExpiresAt.After(first.Backend.LeaseExpiresAt) { - t.Fatal("renewal did not extend the lease") - } -} - -func TestSolutionRegistry_StalePublisherCannotOverwrite(t *testing.T) { - id := testSolutionID(t) - first, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("first: %v", err) - } - - moved := backendWrite(id, "acme", "http://upstream-2.example:8080") - moved.ExpectedRevision = &first.Revision - second, err := testService.PutSolutionRegistration(testCtx, moved) - if err != nil { - t.Fatalf("compare-and-swap update: %v", err) - } - - // The first publisher retries against the revision it still holds. - behind := backendWrite(id, "acme", "http://upstream-3.example:8080") - behind.ExpectedRevision = &first.Revision - if _, err := testService.PutSolutionRegistration(testCtx, behind); err != business.ErrSolutionRegistrationStale { - t.Fatalf("err = %v, want stale", err) - } - - current, _, err := testService.ListSolutionRegistrations(testCtx, false) - if err != nil { - t.Fatalf("list: %v", err) - } - for _, candidate := range current { - if candidate.SolutionID == id && candidate.Backend.Upstream != "http://upstream-2.example:8080" { - t.Fatalf("stale write landed: %q", candidate.Backend.Upstream) - } - if candidate.SolutionID == id && candidate.Revision != second.Revision { - t.Fatalf("revision = %d, want %d", candidate.Revision, second.Revision) - } - } -} - -func TestSolutionRegistry_ForeignPublisherRefused(t *testing.T) { - id := testSolutionID(t) - if _, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")); err != nil { - t.Fatalf("first claim: %v", err) - } - if _, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "other", "http://attacker.example:8080")); err != business.ErrSolutionPublisherMismatch { - t.Fatalf("err = %v, want publisher mismatch", err) - } -} - -// Deletion must remove routing and page availability in one write, and the -// tombstone it leaves must survive a delayed heartbeat from the old deployment. -func TestSolutionRegistry_DeleteTombstonesAndBlocksResurrection(t *testing.T) { - id := testSolutionID(t) - if _, err := testService.PutSolutionRegistration(testCtx, frontendWrite(id, "acme", `{"id":"`+id+`"}`)); err != nil { - t.Fatalf("frontend half: %v", err) - } - if _, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")); err != nil { - t.Fatalf("backend half: %v", err) - } - - tombstone, err := testService.DeleteSolutionRegistration(testCtx, id, nil) - if err != nil { - t.Fatalf("delete: %v", err) - } - if tombstone.TombstonedAt == nil || tombstone.Frontend != nil || tombstone.Backend != nil { - t.Fatalf("tombstone kept endpoints: %+v", tombstone) - } - - records, _, err := testService.ListSolutionRegistrations(testCtx, false) - if err != nil { - t.Fatalf("list: %v", err) - } - for _, candidate := range records { - if candidate.SolutionID == id { - t.Fatal("a tombstoned registration is still being served") - } - } - - // The retiring deployment heartbeats on. - if _, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")); err != business.ErrSolutionRegistrationTombstoned { - t.Fatalf("err = %v, want tombstoned", err) - } - - // And a deliberate re-registration, naming the tombstone, is admitted. - reactivate := backendWrite(id, "acme", "http://upstream.example:8080") - reactivate.ExpectedRevision = &tombstone.Revision - revived, err := testService.PutSolutionRegistration(testCtx, reactivate) - if err != nil { - t.Fatalf("reactivation: %v", err) - } - if revived.TombstonedAt != nil { - t.Fatal("reactivation left the tombstone in place") - } - if revived.Frontend != nil { - t.Fatal("reactivation must not restore the half the publisher did not re-register") - } -} - -func TestSolutionRegistry_DeleteIsIdempotentAndBoundedToKnownRecords(t *testing.T) { - id := testSolutionID(t) - if _, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")); err != nil { - t.Fatalf("register: %v", err) - } - first, err := testService.DeleteSolutionRegistration(testCtx, id, nil) - if err != nil { - t.Fatalf("delete: %v", err) - } - again, err := testService.DeleteSolutionRegistration(testCtx, id, nil) - if err != nil { - t.Fatalf("repeat delete: %v", err) - } - if again.Revision != first.Revision { - t.Fatalf("a repeated delete advanced the revision %d -> %d", first.Revision, again.Revision) - } - if _, err := testService.DeleteSolutionRegistration(testCtx, testSolutionID(t), nil); err != business.ErrSolutionRegistrationNotFound { - t.Fatalf("err = %v, want not found", err) - } -} - -// A write must name exactly one half. The service rejects both-or-neither -// before anything reaches the record, so a malformed registrant cannot store a -// half it did not mean to claim. -func TestSolutionRegistry_RejectsWriteWithoutExactlyOneHalf(t *testing.T) { - id := testSolutionID(t) - both := frontendWrite(id, "acme", `{}`) - both.Backend = &business.SolutionBackendRegistration{ - Upstream: "http://upstream.example:8080", - ServiceAlias: id, - } - if _, err := testService.PutSolutionRegistration(testCtx, both); err != business.ErrSolutionRegistrationHalfMissing { - t.Fatalf("err = %v, want half missing", err) - } - neither := business.SolutionRegistrationWrite{ - SolutionID: id, Publisher: "acme", Lease: time.Minute, - } - if _, err := testService.PutSolutionRegistration(testCtx, neither); err != business.ErrSolutionRegistrationHalfMissing { - t.Fatalf("err = %v, want half missing", err) - } -} - -// A write naming no solution id or no publisher is not addressable at all, so -// it must not be reported as a problem with the halves it does carry. -func TestSolutionRegistry_RejectsWriteWithoutIdentity(t *testing.T) { - noID := frontendWrite("", "acme", `{}`) - if _, err := testService.PutSolutionRegistration(testCtx, noID); err != business.ErrSolutionRegistrationIdentityRequired { - t.Fatalf("err = %v, want identity required", err) - } - noPublisher := frontendWrite(testSolutionID(t), "", `{}`) - if _, err := testService.PutSolutionRegistration(testCtx, noPublisher); err != business.ErrSolutionRegistrationIdentityRequired { - t.Fatalf("err = %v, want identity required", err) - } -} - -// The host assigns a solution's runtime boundary seed (issue #1015). -// -// It is the column's DEFAULT that assigns it, the upsert's deliberate omission -// of the column that keeps it, and a UNIQUE constraint that keeps two -// registrations from sharing one — all facts about the relation, so they belong -// here rather than in the pure planner tests. What has to hold: the seed is -// there from the first write and the same through every later write of either -// half, it survives a tombstone and a reactivation, two solutions never share -// one, and the boundary the mint seals is DERIVED from it per organization so -// one tenant's is not another's. -func TestSolutionRegistry_RuntimeBoundarySeedIsAssignedOnceByTheHost(t *testing.T) { - id := testSolutionID(t) - const orgOne = "019f6bf7-5b4b-74e5-8c17-0922590b0001" - const orgTwo = "019f6bf7-5b4b-74e5-8c17-0922590b0002" - - first, err := testService.PutSolutionRegistration(testCtx, frontendWrite(id, "acme", `{"id":"`+id+`"}`)) - if err != nil { - t.Fatalf("frontend half: %v", err) - } - seed := first.RuntimeBoundary - if seed == "" { - t.Fatal("the first write left the registration with no runtime boundary seed") - } - - backend, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("backend half: %v", err) - } - if backend.RuntimeBoundary != seed { - t.Fatalf("the second half moved the seed %q -> %q", seed, backend.RuntimeBoundary) - } - - // A heartbeat (identical content, no revision advance) and a real - // replacement both keep it: a renewed Work Context has to be sealed under - // the same boundary as the one it replaces, which is the whole point. - renewed, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")) - if err != nil { - t.Fatalf("renewal: %v", err) - } - if renewed.RuntimeBoundary != seed { - t.Fatalf("a renewal moved the seed to %q", renewed.RuntimeBoundary) - } - replacement := backendWrite(id, "acme", "http://moved.example:8080") - replacement.ExpectedRevision = &renewed.Revision - replaced, err := testService.PutSolutionRegistration(testCtx, replacement) - if err != nil { - t.Fatalf("replacement: %v", err) - } - if replaced.RuntimeBoundary != seed { - t.Fatalf("a replaced half moved the seed to %q", replaced.RuntimeBoundary) - } - - // The read the Work Context mint makes answers the same seed, with the - // publisher of record and a backend half that is serving. - held, err := testStore.SolutionRuntimeBoundarySeed(testCtx, id) - if err != nil { - t.Fatalf("mint lookup: %v", err) - } - if held.Seed != seed { - t.Fatalf("the mint would derive from %q, not the registration's %q", held.Seed, seed) - } - if held.Publisher != "acme" { - t.Fatalf("mint lookup publisher = %q, want acme", held.Publisher) - } - if !held.BackendServing { - t.Fatal("a backend half registered one second ago reads as not serving") - } - - // The sealed boundary is derived per organization, so one tenant's is not - // another's and neither is the seed. - one, err := business.SolutionRuntimeBoundary(seed, orgOne) - if err != nil { - t.Fatalf("derive: %v", err) - } - two, err := business.SolutionRuntimeBoundary(seed, orgTwo) - if err != nil { - t.Fatalf("derive: %v", err) - } - if one == two { - t.Fatalf("two organizations share the boundary %q", one) - } - if one == seed || two == seed { - t.Fatal("a derived boundary equals the seed it came from") - } - again, err := business.SolutionRuntimeBoundary(seed, orgOne) - if err != nil || again != one { - t.Fatalf("derivation is not stable: %q then %q (%v)", one, again, err) - } - - // A tombstone keeps the seed — the record is the same solution under the - // same publisher — but a deregistered solution may not mint under it. - tombstone, err := testService.DeleteSolutionRegistration(testCtx, id, nil) - if err != nil { - t.Fatalf("delete: %v", err) - } - if tombstone.RuntimeBoundary != seed { - t.Fatalf("the tombstone moved the seed to %q", tombstone.RuntimeBoundary) - } - if _, err := testStore.SolutionRuntimeBoundarySeed(testCtx, id); err != business.ErrSolutionRegistrationTombstoned { - t.Fatalf("mint lookup on a tombstone: err = %v, want tombstoned", err) - } - - reactivate := backendWrite(id, "acme", "http://upstream.example:8080") - reactivate.ExpectedRevision = &tombstone.Revision - revived, err := testService.PutSolutionRegistration(testCtx, reactivate) - if err != nil { - t.Fatalf("reactivation: %v", err) - } - if revived.RuntimeBoundary != seed { - t.Fatalf("reactivation moved the seed to %q", revived.RuntimeBoundary) - } - - // Another solution gets its own, and the mint refuses one that never - // registered rather than answering an empty seed every solution shares. - other := testSolutionID(t) - second, err := testService.PutSolutionRegistration(testCtx, backendWrite(other, "acme", "http://other.example:8080")) - if err != nil { - t.Fatalf("second solution: %v", err) - } - if second.RuntimeBoundary == seed || second.RuntimeBoundary == "" { - t.Fatalf("two solutions share the seed %q", second.RuntimeBoundary) - } - if _, err := testStore.SolutionRuntimeBoundarySeed(testCtx, testSolutionID(t)); err != business.ErrSolutionRegistrationNotFound { - t.Fatalf("mint lookup on an unregistered solution: err = %v, want not found", err) - } - - // And every seed is readable as a set, which is what the mint's collision - // check reads to refuse a caller-named task_id that is a solution's - // boundary. Both of this test's registrations are in it, tombstone or not. - seeds, err := testStore.SolutionRuntimeBoundarySeeds(testCtx) - if err != nil { - t.Fatalf("seed set: %v", err) - } - if !business.IsSolutionRuntimeBoundary(seeds, one, orgOne) { - t.Fatal("the collision check does not recognise a derived boundary") - } - if !business.IsSolutionRuntimeBoundary(seeds, seed, orgOne) { - t.Fatal("the collision check does not recognise a seed") - } - if business.IsSolutionRuntimeBoundary(seeds, uuid.NewString(), orgOne) { - t.Fatal("the collision check refuses an unrelated task id") - } -} - -// A seed is UNIQUE, so the collision check can never be ambiguous about which -// solution it protected. Nothing in the service writes the column, so the only -// way to prove the constraint is to reach for it directly. -func TestSolutionRegistry_RuntimeBoundarySeedIsUnique(t *testing.T) { - first := testSolutionID(t) - second := testSolutionID(t) - for _, id := range []string{first, second} { - if _, err := testService.PutSolutionRegistration(testCtx, backendWrite(id, "acme", "http://upstream.example:8080")); err != nil { - t.Fatalf("register %s: %v", id, err) - } - } - held, err := testStore.SolutionRuntimeBoundarySeed(testCtx, first) - if err != nil { - t.Fatalf("read %s: %v", first, err) - } - err = testStore.ForceSolutionRuntimeBoundarySeedForTest(testCtx, second, held.Seed) - if err == nil { - t.Fatal("two registrations were allowed to share one seed") - } - if !strings.Contains(err.Error(), "runtime_boundary") { - t.Fatalf("err = %v, want the runtime_boundary unique violation", err) - } -} diff --git a/module/services/accounts/code/pkg/business/solution_registry_test.go b/module/services/accounts/code/pkg/business/solution_registry_test.go deleted file mode 100644 index 46b4b5034..000000000 --- a/module/services/accounts/code/pkg/business/solution_registry_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package business - -import ( - "testing" - "time" -) - -// The registry's compare-and-swap, tombstone, and lease rules decided here are -// what both surfaces depend on, so they are exercised twice: once against the -// pure planner, where every branch is reachable without a database, and once -// end to end against Postgres, where the CHECK constraints and the revision -// sequence are the things actually under test. - -func frontendWrite(id, publisher, manifest string) SolutionRegistrationWrite { - return SolutionRegistrationWrite{ - SolutionID: id, - Publisher: publisher, - Lease: time.Minute, - Frontend: &SolutionFrontendRegistration{Manifest: manifest}, - } -} - -func backendWrite(id, publisher, upstream string) SolutionRegistrationWrite { - return SolutionRegistrationWrite{ - SolutionID: id, - Publisher: publisher, - Lease: time.Minute, - Backend: &SolutionBackendRegistration{ - Upstream: upstream, - ServiceAlias: id, - }, - } -} - -func TestPlanSolutionRegistration_FirstClaimNeedsNoRevision(t *testing.T) { - now := time.Now().UTC() - next, changed, err := planSolutionRegistrationWrite(nil, frontendWrite("demo", "acme", `{"id":"demo"}`), now) - if err != nil { - t.Fatalf("first claim: %v", err) - } - if !changed { - t.Fatal("first claim must advance the record") - } - if next.Frontend == nil || next.Backend != nil { - t.Fatal("first claim must set only the half it carried") - } - if !next.Frontend.LeaseExpiresAt.Equal(now.Add(time.Minute)) { - t.Fatalf("lease = %v, want %v", next.Frontend.LeaseExpiresAt, now.Add(time.Minute)) - } -} - -// A publisher holding a revision for a record that no longer exists is working -// from a view that has been deleted out from under it; recreating the record -// silently would be a resurrection by another name. -func TestPlanSolutionRegistration_RevisionAgainstAbsentRecordIsStale(t *testing.T) { - stale := int64(7) - write := frontendWrite("demo", "acme", `{"id":"demo"}`) - write.ExpectedRevision = &stale - if _, _, err := planSolutionRegistrationWrite(nil, write, time.Now().UTC()); err != ErrSolutionRegistrationStale { - t.Fatalf("err = %v, want stale", err) - } -} - -func TestPlanSolutionRegistration_ForeignPublisherRefused(t *testing.T) { - current := &SolutionRegistration{SolutionID: "demo", Publisher: "acme", Revision: 3} - _, _, err := planSolutionRegistrationWrite(current, frontendWrite("demo", "other", `{}`), time.Now().UTC()) - if err != ErrSolutionPublisherMismatch { - t.Fatalf("err = %v, want publisher mismatch", err) - } -} - -// Replacing a half that already holds different content is the case that needs -// a token; claiming a half the record does not hold yet is not. -func TestPlanSolutionRegistration_ReplacingHalfRequiresRevision(t *testing.T) { - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 3, - Frontend: &SolutionFrontendHalf{Revision: 3, Manifest: `{"v":1}`, LeaseExpiresAt: time.Now().Add(time.Hour)}, - } - _, _, err := planSolutionRegistrationWrite(current, frontendWrite("demo", "acme", `{"v":2}`), time.Now().UTC()) - if err != ErrSolutionRegistrationRevisionRequired { - t.Fatalf("err = %v, want revision required", err) - } - - claimOther := backendWrite("demo", "acme", "http://upstream.example:8080") - if _, changed, err := planSolutionRegistrationWrite(current, claimOther, time.Now().UTC()); err != nil || !changed { - t.Fatalf("claiming the unheld half: changed=%v err=%v", changed, err) - } -} - -func TestPlanSolutionRegistration_StaleRevisionRefused(t *testing.T) { - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 9, - Frontend: &SolutionFrontendHalf{Revision: 9, Manifest: `{"v":1}`, LeaseExpiresAt: time.Now().Add(time.Hour)}, - } - behind := int64(4) - write := frontendWrite("demo", "acme", `{"v":2}`) - write.ExpectedRevision = &behind - if _, _, err := planSolutionRegistrationWrite(current, write, time.Now().UTC()); err != ErrSolutionRegistrationStale { - t.Fatalf("err = %v, want stale", err) - } -} - -// A heartbeat re-sends identical content. It must refresh the lease without -// advancing the revision, or every replica's cache churns at heartbeat cadence. -func TestPlanSolutionRegistration_IdenticalContentRenewsWithoutAdvancing(t *testing.T) { - now := time.Now().UTC() - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 5, - Frontend: &SolutionFrontendHalf{Revision: 5, Manifest: `{"v":1}`, LeaseExpiresAt: now.Add(time.Second)}, - } - next, changed, err := planSolutionRegistrationWrite(current, frontendWrite("demo", "acme", `{"v":1}`), now) - if err != nil { - t.Fatalf("renewal: %v", err) - } - if changed { - t.Fatal("a renewal must not advance the revision") - } - if !next.Frontend.LeaseExpiresAt.Equal(now.Add(time.Minute)) { - t.Fatalf("lease not extended: %v", next.Frontend.LeaseExpiresAt) - } - if current.Frontend.LeaseExpiresAt != now.Add(time.Second) { - t.Fatal("planning mutated the record it was given") - } -} - -// The registry stores the manifest as jsonb, so the record read back is Postgres' -// re-serialization of what the host sent — keys reordered, spaces added — never -// the host's compact bytes. A heartbeat re-sending the same manifest is still a -// renewal: compared byte for byte, every beat advanced the revision and wrote a -// registration_updated audit event. -func TestPlanSolutionRegistration_StoredJSONBFormIsTheSameManifest(t *testing.T) { - now := time.Now().UTC() - sent := `{"id":"demo","nav":{"title":"Demo","path":"/s/demo","order":60},"frontend":{"type":"module-federation"}}` - stored := `{"id": "demo", "nav": {"path": "/s/demo", "order": 60, "title": "Demo"}, "frontend": {"type": "module-federation"}}` - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 5, - Frontend: &SolutionFrontendHalf{Revision: 5, Manifest: stored, LeaseExpiresAt: now.Add(time.Second)}, - } - next, changed, err := planSolutionRegistrationWrite(current, frontendWrite("demo", "acme", sent), now) - if err != nil { - t.Fatalf("renewal: %v", err) - } - if changed || next.Revision != 5 { - t.Fatalf("the stored form of the same manifest advanced the registration (changed=%v revision=%d)", changed, next.Revision) - } - if !next.Frontend.LeaseExpiresAt.Equal(now.Add(time.Minute)) { - t.Fatalf("lease not extended: %v", next.Frontend.LeaseExpiresAt) - } - - // A real change still is one, however the stored text is spelled. - for _, edited := range []string{ - `{"id":"demo","nav":{"title":"Demo","path":"/s/demo","order":61},"frontend":{"type":"module-federation"}}`, - `{"id":"demo","nav":{"title":"Demo","path":"/s/demo","order":60.5},"frontend":{"type":"module-federation"}}`, - `{"id":"demo","nav":{"title":"Demo","path":"/s/demo","order":60},"frontend":{"type":"module-federation"},"extra":null}`, - `not json`, - } { - write := frontendWrite("demo", "acme", edited) - revision := int64(5) - write.ExpectedRevision = &revision - if _, changed, err := planSolutionRegistrationWrite(current, write, now); err != nil || !changed { - t.Fatalf("an edited manifest read as unchanged: %s (err=%v)", edited, err) - } - } -} - -// jsonb stores numbers as numeric, which never renders an exponent: a manifest -// the host sent as 1e-7 reads back as 0.0000001. Comparing the literals as -// written, every beat of such a manifest advanced the revision — the same -// churn the jsonb round-trip caused, surviving for exactly the manifests whose -// numbers Postgres rewrites. -func TestPlanSolutionRegistration_PostgresNumericFormIsTheSameManifest(t *testing.T) { - now := time.Now().UTC() - for _, spelling := range []struct{ stored, sent string }{ - {`{"order": 0.0000001}`, `{"order":1e-7}`}, - {`{"order": 1000000000000000000000}`, `{"order":1e+21}`}, - {`{"order": 100}`, `{"order":1E2}`}, - {`{"order": 1.50}`, `{"order":1.5}`}, - } { - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 5, - Frontend: &SolutionFrontendHalf{Revision: 5, Manifest: spelling.stored, LeaseExpiresAt: now.Add(time.Second)}, - } - next, changed, err := planSolutionRegistrationWrite(current, frontendWrite("demo", "acme", spelling.sent), now) - if err != nil { - t.Fatalf("%s: renewal: %v", spelling.sent, err) - } - if changed || next.Revision != 5 { - t.Fatalf("%s stored as %s advanced the registration (changed=%v revision=%d)", spelling.sent, spelling.stored, changed, next.Revision) - } - } - - // A different number is still a different manifest, and a literal too large - // to compare by value falls back to comparing the text — which reports a - // change, the answer the numeric column refuses the write with anyway. - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 5, - Frontend: &SolutionFrontendHalf{Revision: 5, Manifest: `{"order": 0.0000001}`, LeaseExpiresAt: now.Add(time.Second)}, - } - for _, edited := range []string{ - `{"order":1e-8}`, - `{"order":2e-7}`, - `{"order":1e999999999}`, - `{"order":"1e-7"}`, - } { - write := frontendWrite("demo", "acme", edited) - revision := int64(5) - write.ExpectedRevision = &revision - if _, changed, err := planSolutionRegistrationWrite(current, write, now); err != nil || !changed { - t.Fatalf("an edited manifest read as unchanged: %s (err=%v)", edited, err) - } - } -} - -// The resurrection rule: a retiring deployment's delayed retry carries either no -// token or its pre-deletion one, and both must lose to the tombstone. -func TestPlanSolutionRegistration_TombstoneBlocksResurrection(t *testing.T) { - tombstoned := time.Now().UTC() - current := &SolutionRegistration{ - SolutionID: "demo", Publisher: "acme", Revision: 11, TombstonedAt: &tombstoned, - } - if _, _, err := planSolutionRegistrationWrite(current, frontendWrite("demo", "acme", `{}`), tombstoned); err != ErrSolutionRegistrationTombstoned { - t.Fatalf("err = %v, want tombstoned", err) - } - - preDeletion := int64(10) - retry := frontendWrite("demo", "acme", `{}`) - retry.ExpectedRevision = &preDeletion - if _, _, err := planSolutionRegistrationWrite(current, retry, tombstoned); err != ErrSolutionRegistrationStale { - t.Fatalf("err = %v, want stale", err) - } - - // A deliberate re-registration names the tombstone's own revision. - reactivate := frontendWrite("demo", "acme", `{}`) - reactivate.ExpectedRevision = ¤t.Revision - next, changed, err := planSolutionRegistrationWrite(current, reactivate, tombstoned) - if err != nil || !changed { - t.Fatalf("reactivation: changed=%v err=%v", changed, err) - } - if next.TombstonedAt != nil || next.Frontend == nil { - t.Fatal("reactivation must clear the tombstone and set the half") - } -} - -func TestSolutionRegistrationStatus(t *testing.T) { - now := time.Now().UTC() - live := now.Add(time.Hour) - lapsed := now.Add(-time.Second) - tombstoned := now - - for _, tc := range []struct { - name string - record SolutionRegistration - want SolutionRegistrationStatus - }{ - { - name: "one half is pending, never active", - record: SolutionRegistration{Frontend: &SolutionFrontendHalf{LeaseExpiresAt: live}}, - want: SolutionRegistrationPending, - }, - { - // A half that registered once and stopped renewing is dead, not - // waiting for its partner. - name: "a lapsed lease on the only half is expired, not pending", - record: SolutionRegistration{Frontend: &SolutionFrontendHalf{LeaseExpiresAt: lapsed}}, - want: SolutionRegistrationExpired, - }, - { - name: "both halves live", - record: SolutionRegistration{ - Frontend: &SolutionFrontendHalf{LeaseExpiresAt: live}, - Backend: &SolutionBackendHalf{LeaseExpiresAt: live}, - }, - want: SolutionRegistrationActive, - }, - { - name: "either lease lapsed", - record: SolutionRegistration{ - Frontend: &SolutionFrontendHalf{LeaseExpiresAt: live}, - Backend: &SolutionBackendHalf{LeaseExpiresAt: lapsed}, - }, - want: SolutionRegistrationExpired, - }, - { - name: "contract mismatch outranks a lapsed lease", - record: SolutionRegistration{ - Frontend: &SolutionFrontendHalf{LeaseExpiresAt: lapsed, ContractVersion: "v1"}, - Backend: &SolutionBackendHalf{LeaseExpiresAt: lapsed, ContractVersion: "v2"}, - }, - want: SolutionRegistrationIncompatible, - }, - { - name: "a tombstone outranks everything", - record: SolutionRegistration{ - TombstonedAt: &tombstoned, - Frontend: &SolutionFrontendHalf{LeaseExpiresAt: live}, - Backend: &SolutionBackendHalf{LeaseExpiresAt: live}, - }, - want: SolutionRegistrationTombstoned, - }, - } { - t.Run(tc.name, func(t *testing.T) { - if got := tc.record.Status(now); got != tc.want { - t.Fatalf("status = %q, want %q", got, tc.want) - } - }) - } -} diff --git a/module/services/accounts/code/pkg/business/solution_targets.go b/module/services/accounts/code/pkg/business/solution_targets.go new file mode 100644 index 000000000..47bb00dd9 --- /dev/null +++ b/module/services/accounts/code/pkg/business/solution_targets.go @@ -0,0 +1,129 @@ +package business + +import "time" + +// The identity an organisation installs (the adversarial review's §9). +// +// A solution's registry key is its route alias, and an alias is deliberately +// REUSABLE: a tombstoned one may be claimed by another binding. An installation +// that named the alias would therefore transfer consent without anybody acting — +// org A installs the binding behind `reports` and exposes it to a team, that +// binding is withdrawn, a different binding takes `reports`, and it inherits A's +// installation and A's team grant. +// +// A foreign key to the registry would make that inheritance formal rather than +// accidental: it guarantees referential integrity, not continuity of authorised +// identity. Core states the intended rule outright — a second instance "gets its +// own binding ID and inherits nothing from this one — no route, no generation +// history, no installation". +// +// So the thing an organisation installs has its own identity. A SolutionTarget is +// ONE CONTINUOUS PERIOD of one binding's presence: minted when a present +// generation applies for a binding with no live target, closed by that binding's +// tombstone generation, and never reused. A binding withdrawn and later delivered +// again gets a new target, because the tombstone ended the presence that was +// consented to and consenting again is a dynamic act by a person. +// +// The alias lives on the target and moves with a generation that renames it. That +// is exactly why it is not the identity. +type SolutionTarget struct { + // ID is the immutable identity. An installation names this. + ID string + // BindingID is whose presence this target records. Not unique on its own: a + // binding withdrawn and re-presented has one target per period. + BindingID string + // SolutionID is the route alias as of the applied generation, and may move. + SolutionID string + // OpenedGeneration is the generation that first presented this period. + OpenedGeneration uint64 + // ClosedGeneration is the tombstone generation that withdrew it, or nil while + // the target is live. + ClosedGeneration *uint64 + OpenedAt time.Time + ClosedAt *time.Time +} + +// Live reports whether this target is the one an installation may currently name. +func (t *SolutionTarget) Live() bool { return t != nil && t.ClosedGeneration == nil } + +// RevokedInstallation is one installation a withdrawal ended: enough to attribute +// the audit event, and nothing more. The caller emits per organisation because an +// installation-revoked event belongs to the tenant whose consent ended, and a +// withdrawal can end several at once. +type RevokedInstallation struct { + InstallationID string + OrgID string +} + +// AvailableSolutionQuery narrows the catalogue read. It exists so the listing and +// the single-target check an install performs are ONE query with one acceptance +// predicate: a second query that meant to agree with this one is how an install +// ends up accepting something the catalogue would not offer. +type AvailableSolutionQuery struct { + // OrgID is whose installations decide the `installed` flag. Empty answers + // false for every row, which is what a check that does not care wants. + OrgID string + // TargetID narrows to one target. When set, Cursor is ignored. + TargetID string + Cursor string + Limit int +} + +// AvailableSolutionTarget is one row of the catalogue: a solution target an +// administrator may install right now. +// +// It is built from ACCEPTED applied state and deliberately not from the +// diagnostic binding listing, which also reports desired generations that were +// refused. A refused generation is something an operator must see and something +// an administrator must not be able to consent to: consenting to a release this +// host never admitted records authority over a presence that does not exist. +type AvailableSolutionTarget struct { + TargetID string + BindingID string + RouteAlias string + // The applied release, split so a consumer renders it without parsing. + ReleasePublisher string + ReleaseName string + ReleaseVersion string + OpenedGeneration uint64 + // AppliedGeneration is the binding's newest applied generation, which is >= + // OpenedGeneration once a later present generation has been applied. + AppliedGeneration uint64 + // Installed reports whether the asking organisation already holds an ACTIVE + // installation of this target. + Installed bool +} + +// SolutionGenerationDecision is one entry in the generation history: a generation +// this host decided about, and what it decided (migration 22). +// +// It exists because nothing else can answer "what did this component move +// through". The binding row holds one desired and one applied generation, and its +// pending reason is the CURRENT refusal, overwritten every pass — so a superseded +// refusal leaves no trace at all. +type SolutionGenerationDecision struct { + BindingID string + // TargetID is the installable identity the decision applied into, empty for a + // refusal and for a tombstone of a binding that never applied. + TargetID string + Generation uint64 + Digest string + // Decision is applied | current | refused | tombstoned. `current` is recorded + // deliberately: a re-read that changed nothing is still evidence the document + // was present and unchanged, which is what separates "delivery stopped" from + // "delivery kept saying the same thing". + Decision string + // Reason explains a refusal and is set for nothing else. + Reason string + Release string + RegistryRevision *int64 + DecidedAt time.Time +} + +// Generation decisions, as recorded. +const ( + SolutionGenerationApplied = "applied" + SolutionGenerationCurrent = "current" + SolutionGenerationRefused = "refused" + SolutionGenerationTombstoned = "tombstoned" +) diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 70897f574..4238758f9 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -279,7 +279,7 @@ func (r ModulePrincipalRegistry) SourceDelegationTargets() []SourceDelegationTar // role a later mint could re-check. The source then reads as having no // delegation until an administrator of the organization reconnects it. func (s *Service) recordSourceDelegationsTx(ctx context.Context, actorID, orgID, sourceID string) error { - targets := s.modulePrincipals.SourceDelegationTargets() + targets := s.declaredModules().SourceDelegationTargets() if len(targets) == 0 { return nil } @@ -386,6 +386,26 @@ func (s *Service) ListSourceDelegations(ctx context.Context, orgID, sourceID str // returns it as stored. Revoking one already revoked changes and records // nothing. A delegation that is not in the organization is // ErrSourceDelegationNotFound, whether or not it exists elsewhere. +// +// A NARROWING — the module binding stops holding the owner's authority over +// that source — so it runs under the policy log: appended and receipted before +// the row is marked, with the revocation and the receipt's commit in one +// transaction, and a refusal rather than an unwitnessed revocation when the log +// cannot be reached. +// +// That transaction is the policy log's control-plane one, because the receipt +// relation is control-plane only and the two writes must be atomic. The +// organisation's own scoping is not lost: both statements below carry orgID in +// their filter — the revoke through SourceDelegationFilter, the fallback read +// through GetSourceDelegation — so a delegation of another organisation is +// still ErrSourceDelegationNotFound rather than revocable by id. +// +// Revoking an already-revoked delegation appends an entry and commits nothing +// else, which is the correct trade and not an oversight: the alternative is +// reading the row before the append to find out, and a read then cannot bind +// what the transaction will find. An over-recorded entry replays to the same +// answer (see newPolicyLogOperationID); a narrowing that skipped the log +// because a pre-read said it would be a no-op would not. func (s *Service) RevokeSourceDelegation(ctx context.Context, actorID, orgID, id string) (*SourceDelegation, error) { orgID = strings.TrimSpace(orgID) id = strings.TrimSpace(id) @@ -393,25 +413,27 @@ func (s *Service) RevokeSourceDelegation(ctx context.Context, actorID, orgID, id return nil, errors.New("org id and delegation id are required") } var out *SourceDelegation - if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { - revoked, err := s.store.RevokeSourceDelegations(ctx, SourceDelegationFilter{OrgID: orgID, ID: id}, SourceDelegationRevokedByAdmin, actorID) - if err != nil { - return err - } - if len(revoked) == 1 { - out = revoked[0] - return s.emitSourceDelegationRevokedTx(ctx, actorID, out, SourceDelegationRevokedByAdmin) - } - existing, err := s.store.GetSourceDelegation(ctx, orgID, id) - if err != nil { - return err - } - if existing == nil { - return ErrSourceDelegationNotFound - } - out = existing - return nil - }); err != nil { + if err := s.WithPolicyLoggedNarrowing(ctx, + revokeSourceDelegationPolicyLogEntry(actorID, orgID, id), + func(ctx context.Context) error { + revoked, err := s.store.RevokeSourceDelegations(ctx, SourceDelegationFilter{OrgID: orgID, ID: id}, SourceDelegationRevokedByAdmin, actorID) + if err != nil { + return err + } + if len(revoked) == 1 { + out = revoked[0] + return s.emitSourceDelegationRevokedTx(ctx, actorID, out, SourceDelegationRevokedByAdmin) + } + existing, err := s.store.GetSourceDelegation(ctx, orgID, id) + if err != nil { + return err + } + if existing == nil { + return ErrSourceDelegationNotFound + } + out = existing + return nil + }); err != nil { return nil, err } return out, nil @@ -484,7 +506,7 @@ func (s *Service) AuthorizeSourceOperationContext(ctx context.Context, prefix, s if err != nil { return SourceOperationContextAuthority{}, err } - grant, registered := s.modulePrincipals[identity.PrincipalID] + grant, registered := s.declaredModules()[identity.PrincipalID] if !registered { return SourceOperationContextAuthority{}, ErrModuleRegistrationDenied } @@ -520,7 +542,7 @@ func (s *Service) AuthorizeSourceOperationContext(ctx context.Context, prefix, s func (s *Service) AuthorizeDelegationReferenceExchange( ctx context.Context, caller ModuleCaller, delegationID, bindingID string, lookup bool, ) (SourceOperationContextAuthority, error) { - grant, err := s.moduleGrant(caller) + grant, err := s.moduleCapability(ctx, caller) if err != nil { return SourceOperationContextAuthority{}, err } @@ -546,7 +568,7 @@ func (s *Service) AuthorizeDelegationReferenceExchange( } // The delegating module is the one the person granted to, which is not // the caller: the caller is the module that grant authorizes calling. - delegating, declared := s.modulePrincipals[ModulePrincipalID(delegation.ModulePrefix)] + delegating, declared := s.declaredModules()[ModulePrincipalID(delegation.ModulePrefix)] if !declared { return ErrSourceDelegationInvalid } @@ -795,11 +817,11 @@ func (s *Service) confirmSourceDelegationTx( // and some actor is; the module grant returned is that actor's. Every other // context is not one (ok false) and takes the checks it always took. func (s *Service) sourceDelegationShape(ownerPrincipalID string, actorPrincipalIDs []string) (ModulePrincipalGrant, bool) { - if _, ownerIsModule := s.modulePrincipals[ownerPrincipalID]; ownerIsModule { + if _, ownerIsModule := s.declaredModules()[ownerPrincipalID]; ownerIsModule { return ModulePrincipalGrant{}, false } for _, actor := range actorPrincipalIDs { - if grant, isModule := s.modulePrincipals[actor]; isModule { + if grant, isModule := s.declaredModules()[actor]; isModule { return grant, true } } diff --git a/module/services/accounts/code/pkg/business/store.go b/module/services/accounts/code/pkg/business/store.go index 4832a9009..98f284ccb 100644 --- a/module/services/accounts/code/pkg/business/store.go +++ b/module/services/accounts/code/pkg/business/store.go @@ -677,6 +677,68 @@ type Store interface { SaveSolutionRegistration(ctx context.Context, record *SolutionRegistration) error ListSolutionRegistrations(ctx context.Context, includeTombstoned bool) ([]*SolutionRegistration, int64, error) + // Declared solution presence (issue #952). solution_host_bindings is a + // control-plane relation holding one row per binding ID: the generation + // delivery is showing this host, the generation the host applied and the + // registry record it applied into, and why a desired generation is not the + // applied one. + // + // - GetSolutionHostBindingForUpdate returns nil when the binding has never + // been delivered, and row-locks the record otherwise. The lock is what + // makes two replicas reconciling the same pass converge on one applied + // generation instead of applying it twice. + // - SaveSolutionHostBinding writes the whole record. + // - ListSolutionHostBindings returns every record, ordered by binding ID. + // The identity an organisation installs (solution_targets.go). A target is + // one continuous period of one binding's presence, minted on the first + // present generation and closed by the tombstone that withdraws it, so a + // reused route alias can never carry an installation across to a different + // binding. + // + // - GetLiveSolutionTargetForUpdate returns nil when the binding has no OPEN + // target, which covers both "never present" and "withdrawn". + // - OpenSolutionTarget mints one; the partial unique indexes make two + // replicas racing one pass a unique violation rather than two identities. + // - RetargetSolutionTarget moves the alias and never the identity. + // - CloseSolutionTarget ends the period; the row is never deleted. + // - GetSolutionTarget resolves one by its own id, open or closed, for an + // authority decision that must name the BINDING a target recorded + // rather than the route alias the target happens to carry. + GetSolutionTarget(ctx context.Context, targetID string) (*SolutionTarget, error) + GetLiveSolutionTargetForUpdate(ctx context.Context, bindingID string) (*SolutionTarget, error) + OpenSolutionTarget(ctx context.Context, bindingID, solutionID string, generation uint64, now time.Time) (*SolutionTarget, error) + RetargetSolutionTarget(ctx context.Context, targetID, solutionID string, now time.Time) error + CloseSolutionTarget(ctx context.Context, targetID string, generation uint64, now time.Time) error + ListSolutionTargets(ctx context.Context) ([]*SolutionTarget, error) + // - RevokeInstallationsOfTarget ends the consent a withdrawn presence + // held, in the transaction that closed the target, and returns the rows + // it flipped so the caller emits one audit event per organisation. It is + // control-plane: the withdrawal crosses every tenant that installed the + // target, so it cannot run inside one tenant's transaction. + RevokeInstallationsOfTarget(ctx context.Context, targetID, reason string, now time.Time) ([]RevokedInstallation, error) + // - ListAvailableSolutionTargets is the catalogue read: live targets whose + // binding's newest APPLIED generation is a present one, with the alias + // and release that generation carries, and whether the asking + // organisation already holds an active installation. + ListAvailableSolutionTargets(ctx context.Context, query AvailableSolutionQuery) ([]*AvailableSolutionTarget, error) + + // The generation history (migration 22), append-only: the control plane holds + // SELECT and INSERT and nothing else, because a decision is a fact about the + // past. + // + // - RecordSolutionGenerationDecision appends one. It is idempotent on + // (binding, generation, digest, decision), so a pass that re-reads an + // unchanged document does not append a row per pass — a thirty-second + // poll would otherwise reproduce the registration-event incident in a + // different table. + // - ListSolutionGenerationHistory returns the newest decisions first. + RecordSolutionGenerationDecision(ctx context.Context, decision *SolutionGenerationDecision) error + ListSolutionGenerationHistory(ctx context.Context, bindingID string, limit int) ([]*SolutionGenerationDecision, error) + + GetSolutionHostBindingForUpdate(ctx context.Context, bindingID string) (*SolutionHostBindingRecord, error) + SaveSolutionHostBinding(ctx context.Context, record *SolutionHostBindingRecord) error + ListSolutionHostBindings(ctx context.Context) ([]*SolutionHostBindingRecord, error) + // Solution-declared audit event types (solution_audit_events.go). They are // rows of audit_event_types owned by "solution:" — the table // audit_events.event_type is a foreign key into — so every method runs under diff --git a/module/services/accounts/code/pkg/business/story_host_test.go b/module/services/accounts/code/pkg/business/story_host_test.go index 1e9cf681d..1f116fc92 100644 --- a/module/services/accounts/code/pkg/business/story_host_test.go +++ b/module/services/accounts/code/pkg/business/story_host_test.go @@ -55,6 +55,14 @@ func TestStory_HOST_ID_001(t *testing.T) { // admission guard on this path — authentication, membership, the actor // ceiling — unexercised. adapters.WithService(testService) + // The audience this story mints for is a declared MODULE prefix, and the mint + // now holds every audience to the host's derived vocabulary (issue #952). A + // deployment declares it through the principal registry; so does the story. + previousPrincipals := testService.ModulePrincipals() + testService.SetModulePrincipals(business.ModulePrincipalRegistry{ + business.ModulePrincipalID("documents"): {Prefix: "documents"}, + }) + t.Cleanup(func() { testService.SetModulePrincipals(previousPrincipals) }) issuer := &adapters.WorkContextAuthorityServer{} issuer.Configure(adapters.WorkContextAuthorityConfiguration{ Issuer: storyWorkContextIssuer, @@ -166,6 +174,7 @@ func TestStory_HOST_AUD_001(t *testing.T) { jobPool, err := infra.NewJobWorkerPool(ctx) require.NoError(t, err) t.Cleanup(jobPool.Close) + modules.SetModuleAuthorityReads(currentModuleAuthority{}, nil) modules.SetModuleCapabilities(testStore, infra.NewPostgresJobStore(jobPool), business.ModulePrincipalRegistry{ modulePrincipal: {Queues: []string{"documents"}}, }) diff --git a/module/services/accounts/code/pkg/business/teams.go b/module/services/accounts/code/pkg/business/teams.go index 966e8a100..d4aedd9f5 100644 --- a/module/services/accounts/code/pkg/business/teams.go +++ b/module/services/accounts/code/pkg/business/teams.go @@ -3,6 +3,7 @@ package business import ( "context" "errors" + "fmt" "github.com/codefly-dev/core/wool" @@ -77,6 +78,19 @@ func teamRoleToString(role gen.TeamRole) string { } // RemoveTeamMember removes a member from a team. +// +// A NARROWING — a team is a subject roles are granted to, so losing a +// membership reduces what the removed user may do — and it therefore runs under +// the policy log: appended and receipted before the membership goes, with the +// delete and the receipt's commit in one transaction. +// +// That transaction is the policy log's control-plane one, because the receipt +// relation is control-plane only and the two writes must be atomic. The delete +// itself is keyed on (team, user) and carries no organisation column, so +// team_members' tenant policy was what confined it to this organisation; the +// re-read below is that predicate made explicit on the same transaction. It is +// not a convenience check: without it this delete would be addressable across +// tenants by a caller that named another organisation's team. func (s *Service) RemoveTeamMember(ctx context.Context, actorID string, req *gen.RemoveTeamMemberRequest) error { w := wool.Get(ctx).In("RemoveTeamMember") @@ -85,12 +99,24 @@ func (s *Service) RemoveTeamMember(ctx context.Context, actorID string, req *gen return w.Wrapf(err, "cannot resolve team org") } - if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { - if err := s.store.RemoveTeamMember(ctx, req.TeamId, req.UserId); err != nil { - return err - } - return s.emitTx(ctx, actorID, "user", EventTeamMemberRemoved, "team", req.TeamId, orgID) - }); err != nil { + if err := s.WithPolicyLoggedNarrowing(ctx, + removeTeamMemberPolicyLogEntry(actorID, orgID, req.TeamId, req.UserId), + func(ctx context.Context) error { + owning, err := s.store.GetTeamOrgID(ctx, req.TeamId) + if err != nil { + return err + } + if owning != orgID { + return NewStoreError( + fmt.Errorf("team %s belongs to organization %q, not %q", + req.TeamId, owning, orgID), + ErrTypeValidation) + } + if err := s.store.RemoveTeamMember(ctx, req.TeamId, req.UserId); err != nil { + return err + } + return s.emitTx(ctx, actorID, "user", EventTeamMemberRemoved, "team", req.TeamId, orgID) + }); err != nil { return w.Wrapf(err, "cannot remove team member") } return nil diff --git a/module/services/accounts/code/pkg/business/testdata/unrouted_capability_paths.txt b/module/services/accounts/code/pkg/business/testdata/unrouted_capability_paths.txt new file mode 100644 index 000000000..86115814c --- /dev/null +++ b/module/services/accounts/code/pkg/business/testdata/unrouted_capability_paths.txt @@ -0,0 +1,103 @@ +# Functions that resolve the DECLARED module registry — via moduleGrant or +# declaredModules — without re-reading live authority. +# +# THIS FILE ONLY EVER SHRINKS. A new entry fails +# TestEveryModuleCapabilityPathReReadsLiveAuthority; routing one means deleting +# its line. +# +# An earlier version of that gate watched only `moduleGrant`, so a function +# reading `declaredModules()` directly bypassed it and the gate was, in a +# reviewer's words, green about a NAME. Widening it to the registry read itself +# surfaced these paths. They are NOT all the same problem, and the file says +# which is which rather than implying one disposition: +# +# A. CAPABILITY DECISIONS that must be routed through +# Service.AuthorizeModuleCapability. These are the real outstanding work. +module_operation_context.go:ModuleAuthorizeOperationContext +module_operation_context.go:CheckModuleOperationContextRevision +module_registration.go:ModuleAuthorizeWorkContext +source_delegation.go:AuthorizeSourceOperationContext +source_delegation.go:AuthorizeDelegationReferenceExchange +# Arrived with main's executable-artifact consent (#1025), which landed while +# this gate lived on a branch, so neither side could see the other. Both decide +# authority off the declared ceiling: the revocation decides who may withdraw +# consent to run an artifact, and the current-installation read decides what a +# declared module may learn about its own installation. Routing them is this +# file's group-A work and is owed, not waived — and the revocation is owed a +# policy-log receipt in the same pass (see unwitnessedNarrowings), so the two +# belong in one change rather than two. +executable_artifacts.go:RevokeApprovedExecutableArtifact +current_installation.go:ModuleCurrentInstallation +# +# B. WRITES made under a capability already authorised upstream. They re-read the +# registry to resolve a declared value rather than to decide authority, so +# routing them would re-run a decision their caller already made — but they +# are listed because "my caller checked" is exactly the assumption that rots +# when a new caller appears. +solution_audit_events.go:admitDeclaredAuditEventTypesWritten +solution_audit_events.go:releasedAuditNamespace +source_delegation.go:recordSourceDelegationsTx +# A classification helper: it asks only WHETHER a principal is a declared module, +# to pick the delegation's shape. Its answer is used by callers in group A, which +# is where the authority decision belongs. +source_delegation.go:sourceDelegationShape +# ExecutableArtifactPolicyFor resolves WHICH declared policy a module installed, +# so a caller can compare an artifact against it. It decides nothing: every +# caller of it is a group-A path that makes the authority decision itself. Same +# shape as sourceDelegationShape above, and it arrived with #1025. +executable_artifacts.go:ExecutableArtifactPolicyFor +# +# C. NOT capability decisions at all: accessors and listings over the registry, +# reached by paths with their own authorisation. They need a disposition other +# than routing — most likely an explicit marker — and until that exists they +# are here rather than silently excluded by name inside the gate, because +# excluding them there would be the gate's author deciding what counts. +service.go:ModulePrincipals +layered_access.go:ListCollectionAccess +module_capabilities.go:ModuleContentResources +# HostAudiences resolves the host's AUDIENCE VOCABULARY — the closed set of +# strings this deployment serves a capability to (issue #952). A declared module +# prefix is one of its three members, which is why it reads the registry. +# +# It is group C's shape and not group A's, and the reason is sharper than +# "accessor": there is no authority decision here to route, because there is no +# CALLER in it. It answers "is this string one this host serves", a question with +# no tenant, no installation and no principal — asked BEFORE any authority is +# resolved, by requireVocabularyAudience, whose own caller then makes the +# authority decision with the live re-reads that belong to it +# (enforceActorCeiling, resolveAuthority). Routing it would need a caller it does +# not have. +# +# "Re-read it live" has no meaning for the registry half either: which modules a +# composition declares is a deployment fact, like group D's workload binding. The +# half that DOES move at runtime — the declared, non-tombstoned solution bindings +# — is read live from the database on every call, deliberately uncached, so a +# withdrawn solution leaves the vocabulary at once. +work_context_audience.go:HostAudiences +# ListPlatformCatalogue reads the registry to LIST the modules it knows, for a +# platform administrator's catalogue. It is group C's shape — an accessor, not a +# decision — and it is authorised upstream as a platform-admin RPC. +# +# It is on this list because of a change, not a regression: main read +# `s.modulePrincipals` as a bare field, which this gate does not watch, and the +# merge moved it to the `declaredModules()` accessor so the atomic pointer is +# loaded safely. The gate saw it for the first time as a result. That is the gate +# working — a direct field read was a way past it — and the honest disposition is +# to list the path rather than go back to the spelling that hid it. +platform_catalogue.go:ListPlatformCatalogue +# +# D. AUTHENTICATION, which runs BEFORE any capability decision and reads an +# IDENTITY fact rather than an authority one. Routing it through +# AuthorizeModuleCapability would invert the order: that call decides what an +# already-authenticated caller may do, and this is what establishes who the +# caller is. +# +# The value read is the principal's declared workload — the Kubernetes service +# account, namespace and container its pods run as. There is NO live source +# for it and there should not be: it is a deployment fact, fixed by the +# composition, and the whole point of comparing it against a TokenReview is +# that the declaration is the thing the caller cannot influence. "Re-read it +# live" has no meaning here, so group A's disposition does not apply, and +# group C's "needs an explicit marker" is the closest but still wrong — these +# are accessors, this is a check. +module_execution_binding.go:BindExecution diff --git a/module/services/accounts/code/pkg/business/work_context_audience.go b/module/services/accounts/code/pkg/business/work_context_audience.go new file mode 100644 index 000000000..8e0324b19 --- /dev/null +++ b/module/services/accounts/code/pkg/business/work_context_audience.go @@ -0,0 +1,294 @@ +package business + +import ( + "context" + "fmt" + "sort" + "strings" + "time" +) + +// THE HOST'S AUDIENCE VOCABULARY (issue #952). +// +// An audience is what makes two capabilities signed by one key +// non-interchangeable, so it decides which consumer a capability is good at. It +// was free text: any caller could mint under any string, and a per-installation +// `allowed_audiences` list could name one nothing on this host serves. +// +// The vocabulary is now a CLOSED SET, and it is DERIVED rather than written down: +// +// 1. ModuleCapabilitiesAudience — the one consumer a composed module's own +// capability is good for. +// 2. `solution:` for every DECLARED, NON-TOMBSTONED binding. +// 3. The PREFIX of every declared module principal, because a forwarded viewer +// Work Context names the module it was minted for in its audience and the +// content-read surface resolves it AS a module prefix +// (ModuleContentResources, read by delegated_record_access.go and +// readable_sources.go). This member is derived from the composition's +// MODULE_PRINCIPALS registry, so it is no more free text than the other two — +// a deployment declares it, no caller asks for it. **It is a member the +// three-clause ruling did not name**, and it is here because leaving it out +// would make every such ceiling entry dead and take the module content-read +// surface down; flagged for confirmation rather than quietly widened. +// 4. Nothing else. The gateway's own name is never an audience: a capability +// addressed to the forwarding hop is one the hop would consume rather than +// forward. +// +// Derived from delivered presence is the whole point: **the set changes only by +// delivery**. Nobody adds an audience by asking for one, a withdrawal removes it +// with the binding's tombstone, and there is no table an operator edits to widen +// it. The binding id is the key rather than the route alias for the same reason +// the boundary derivation uses it — an alias is reusable, and an audience that +// survived a withdrawal would let a replacement under the same alias be addressed +// as its predecessor. + +const ( + // SolutionAudiencePrefix prefixes a declared solution binding's audience. It + // is a prefix rather than the bare binding id so that a solution audience can + // never collide with the module one or with a future kind's. + SolutionAudiencePrefix = "solution:" + + // ModuleCapabilitiesAudience is the module capability surface's audience. It + // is stated here, beside the set it belongs to, and the adapters' long-standing + // ModuleWorkContextAudience constant is pinned to it by a test — two spellings + // of one audience is exactly the drift this set exists to prevent. + ModuleCapabilitiesAudience = "module-capabilities" +) + +// SolutionAudience is the audience of a declared solution binding. +func SolutionAudience(bindingID string) string { + return SolutionAudiencePrefix + bindingID +} + +// ErrAudienceNotInHostVocabulary is returned for an audience outside the closed +// set. It is a refusal that NAMES the value, because the whole failure mode this +// replaces was a string nobody could trace to a consumer. +var ErrAudienceNotInHostVocabulary = fmt.Errorf("audience is not one this host serves") + +// DeclaredSolutionBindingStore reads the binding ids the audience set is derived +// from. Separate from SolutionRuntimeBoundarySeedStore because the two want +// DIFFERENT rows: the boundary check includes tombstones, since a withdrawn +// solution's runs may still be executing and their boundaries still have to be +// protected, while an audience must not survive the withdrawal that took the +// consumer away. +type DeclaredSolutionBindingStore interface { + // LiveDeclaredSolutionBindingIDs returns the binding id of every declared, + // non-tombstoned registration. + LiveDeclaredSolutionBindingIDs(ctx context.Context) ([]string, error) +} + +// HostAudienceCacheBound is how long a resolved set of declared solution bindings +// may be served before it is re-read. +// +// It is the SAME bound the gateway reads the same registry under, and that is +// deliberate: the host and the edge must agree on how long a withdrawal may take +// to reach a decision, and two different numbers would make the answer depend on +// which one a request happened to hit. It is also four times the reconciler's own +// interval (SolutionHostBindingReconcileInterval), so a withdrawal this replica +// applies itself is visible at once through invalidation, and one ANOTHER replica +// applied is visible within the bound. +const HostAudienceCacheBound = 120 * time.Second + +// declaredAudienceSnapshot is one resolved read of the declared binding ids, with +// the time it was taken. Stored behind an atomic pointer and never mutated, so a +// reader either sees a whole previous snapshot or a whole newer one. +type declaredAudienceSnapshot struct { + bindings []string + at time.Time +} + +// InvalidateHostAudiences drops the cached binding ids, so the next resolution +// re-reads them. +// +// Called by the apply path whenever a declaration changes the declared set — a +// record declared or withdrawn — which is what makes a withdrawal this replica +// performed visible to the very next mint rather than up to the bound later. It is +// called after the write inside the apply transaction, so a transaction that later +// rolls back costs one extra read and never serves a stale set: the conservative +// direction is the only one worth having here. +func (s *Service) InvalidateHostAudiences() { + if s == nil { + return + } + s.declaredAudienceBindings.Store(nil) +} + +// declaredAudienceBindingIDs resolves the declared binding ids, from the cache +// when it is inside the bound and from the control plane when it is not. +// +// Caching THIS half is what keeps the vocabulary off the per-mint hot path. It +// used to open a control-plane transaction on every call — twice per mint, since +// the mint checks the audience and then narrows the actor's ceiling — on a pool +// whose MaxConns is the pgx default and which every other control-plane capability +// shares, including the delivery reconciler. A mint storm therefore queued +// delivery, which is the mechanism that withdraws a compromised solution. +func (s *Service) declaredAudienceBindingIDs(ctx context.Context) ([]string, error) { + now := time.Now + if s.audienceClock != nil { + now = s.audienceClock + } + if snapshot := s.declaredAudienceBindings.Load(); snapshot != nil && + now().Sub(snapshot.at) < HostAudienceCacheBound { + return snapshot.bindings, nil + } + store, ok := s.store.(DeclaredSolutionBindingStore) + if !ok { + // Fail CLOSED and say which capability the deployment is missing. A store + // that cannot answer must not read as "no solution audiences", which would + // refuse every solution mint while looking like a vocabulary decision. + return nil, fmt.Errorf("cannot resolve the host audience vocabulary: store does not read declared solution bindings") + } + var bindings []string + if err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + var err error + bindings, err = store.LiveDeclaredSolutionBindingIDs(ctx) + return err + }); err != nil { + // A failed read does NOT replace the snapshot: an outage must not install an + // empty set that then reads as "this host serves no solutions" for a whole + // bound. The caller fails closed on the error instead. + return nil, fmt.Errorf("cannot resolve the host audience vocabulary: %w", err) + } + s.declaredAudienceBindings.Store(&declaredAudienceSnapshot{bindings: bindings, at: now()}) + return bindings, nil +} + +// HostAudiences is the closed set. +// +// The solution half is served from a snapshot bounded by HostAudienceCacheBound +// and invalidated by the apply path; the module half is composed fresh from the +// in-memory principal registry on every call, so declaring a module takes effect +// at once and needs no invalidation hook. +func (s *Service) HostAudiences(ctx context.Context) (map[string]struct{}, error) { + if s == nil || s.store == nil { + // A deployment with no service wired cannot answer what it serves, and must + // not answer "nothing" either: every mint refuses, loudly, rather than + // panicking on a nil store three frames deeper. + return nil, fmt.Errorf("cannot resolve the host audience vocabulary: accounts service is not configured") + } + bindings, err := s.declaredAudienceBindingIDs(ctx) + if err != nil { + return nil, err + } + modules := s.declaredModules() + set := make(map[string]struct{}, len(bindings)+len(modules)+1) + set[ModuleCapabilitiesAudience] = struct{}{} + for _, binding := range bindings { + if binding = strings.TrimSpace(binding); binding != "" { + set[SolutionAudience(binding)] = struct{}{} + } + } + // A declared module's own prefix. The registry is keyed by principal id, and + // the audience is the PREFIX that id is derived from — the value the + // content-read surface looks a module up by. + for _, grant := range modules { + if prefix := strings.TrimSpace(grant.Prefix); prefix != "" { + set[prefix] = struct{}{} + } + } + return set, nil +} + +// RequireHostAudience refuses an audience outside the closed set, by name. +// +// An empty audience is refused first and separately: it is not "a value this host +// does not serve", it is the absence of the field that decides which consumer a +// capability is good at, and an operator reading the two refusals does different +// things about them. +func (s *Service) RequireHostAudience(ctx context.Context, audience string) error { + if strings.TrimSpace(audience) == "" { + return fmt.Errorf("%w: an audience is required, and a capability with no audience is good at whichever consumer accepts it", + ErrAudienceNotInHostVocabulary) + } + set, err := s.HostAudiences(ctx) + if err != nil { + return err + } + if _, ok := set[audience]; ok { + return nil + } + return fmt.Errorf("%w: %q (%s)", ErrAudienceNotInHostVocabulary, audience, hostAudienceShapes) +} + +// hostAudienceShapes names what this host serves, for a refusal. It is a CONSTANT: +// the same sentence whatever the set contains. +// +// An earlier version counted the members — "%d declared solution binding +// audience(s)" — on the reasoning that counting is not listing. That was wrong in +// its own terms: a count is information about the same secret, so one refused mint +// told any caller how many solutions this deployment runs, and repeating the probe +// told them when that changed, which is to say when a solution was installed or +// withdrawn. The registration surface takes constant-time care never to reveal +// which modules a composition declares; a cardinality channel beside it gives that +// away more slowly and just as surely. +// +// Deleting the counts also deletes the arithmetic that produced them. It derived +// one cardinality by subtracting another from the deduplicated set, so a module +// whose prefix WAS "module-capabilities" reported zero module prefixes when there +// was one, and a prefix beginning "solution:" was counted as a solution. +const hostAudienceShapes = "this host serves " + `"` + ModuleCapabilitiesAudience + `"` + + ", an audience of the form \"" + SolutionAudiencePrefix + "\" for a declared solution binding" + + ", or a declared module prefix" + +// RequireHostAudiences refuses a LIST, naming every entry outside the set. +// +// This is the write-time half of the `allowed_audiences` rule. It reports every +// offending entry rather than the first, because an operator fixing a ceiling is +// editing one list and wants one answer about it. +func (s *Service) RequireHostAudiences(ctx context.Context, audiences []string) error { + if len(audiences) == 0 { + return nil + } + set, err := s.HostAudiences(ctx) + if err != nil { + return err + } + var rejected []string + for _, audience := range audiences { + if strings.TrimSpace(audience) == "" { + rejected = append(rejected, `""`) + continue + } + if _, ok := set[audience]; !ok { + rejected = append(rejected, fmt.Sprintf("%q", audience)) + } + } + if len(rejected) == 0 { + return nil + } + sort.Strings(rejected) + return fmt.Errorf("%w: %s (%s)", + ErrAudienceNotInHostVocabulary, strings.Join(rejected, ", "), hostAudienceShapes) +} + +// LiveHostAudiences narrows a stored ceiling to the audiences the host still +// serves. It is the READ-time half of the rule. +// +// An entry that has left the set is DEAD, not granted: a solution withdrawn after +// an installation named its audience leaves a ceiling entry that no longer +// describes a consumer, and treating it as live would let a capability be minted +// for an audience delivery has taken away. Write-time validation alone cannot +// cover this — the set shrinks by delivery, long after any write. +// +// Returning the narrowed list rather than an error is deliberate: a ceiling with +// one dead entry and two live ones should still allow the two. +func (s *Service) LiveHostAudiences(ctx context.Context, audiences []string) ([]string, error) { + if len(audiences) == 0 { + return nil, nil + } + set, err := s.HostAudiences(ctx) + if err != nil { + return nil, err + } + live := make([]string, 0, len(audiences)) + for _, audience := range audiences { + if _, ok := set[audience]; ok { + live = append(live, audience) + } + } + return live, nil +} + +// SetAudienceClockForTest moves the vocabulary cache's clock. Test-only, named so, +// and the only way to prove the bound expires without sleeping it out. +func (s *Service) SetAudienceClockForTest(now func() time.Time) { s.audienceClock = now } diff --git a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go index 1553a856b..1f3aa8570 100644 --- a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go +++ b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go @@ -41,8 +41,11 @@ func TestAuthorizationCatalogCompilationAndPolicyProjection(t *testing.T) { } } // Artifact approval/authorization/revocation and current installation lookup - // are internal module capabilities, never public caller endpoints. - require.Equal(t, 53, internalCount) + // are internal module capabilities, never public caller endpoints; so is the + // declared-presence RPC this branch adds. Base 49, main +4, this branch +1 — + // picking either side's number would be silently wrong, so this is read off + // the regenerated generated/authz-methods.json. + require.Equal(t, 54, internalCount) require.Equal(t, 17, failClosedCount) require.Equal(t, 2, factorAttemptCount) require.Equal( diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_model.go b/module/services/accounts/code/pkg/cataloggen/deployment_model.go index f5e6c4f5f..7aeebf63f 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_model.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_model.go @@ -273,6 +273,22 @@ func assembleServiceBinding(moduleName, name string, document []byte) (deploymen // manifestCatalogVisibility projects Codefly's independent visibility/location // axes into this catalog's categories. MODULE represents every module, so // narrower allow-lists refuse rather than silently widening the generated policy. +// +// `internal` WITH NO allow-list is the v0.14.0 spelling of what `internal` plus +// `allow-modules: ["*"]` said, and maps to the same category. That is not a +// loosening: core v0.14.0 refuses the key outright — "an allow-list is derived +// from the consumers' declared service dependencies, never written by the module +// it would grant" — so the wildcard was a module answering a question that was +// never its to answer, and deleting it removes a redundant spelling rather than a +// constraint. Which modules actually reach the endpoint is decided by their own +// declared dependencies either way, which is exactly what MODULE means here. +// +// The wildcard is still ACCEPTED, because this generator walks every composed +// module's manifests and the fleet has not finished moving; and a narrower +// allow-list is still REFUSED, because it would need an allow-list-aware catalog +// and core will not let one be authored anyway. So the only two reachable inputs +// both mean "every module that declares the dependency", and nothing silently +// widens. func manifestCatalogVisibility(visibility, location string, allowed []string) (string, error) { if visibility == "" { visibility = "private" @@ -290,8 +306,8 @@ func manifestCatalogVisibility(visibility, location string, allowed []string) (s } return visibility, nil case "internal": - if len(allowed) != 1 || allowed[0] != "*" { - return "", fmt.Errorf("deployment catalog requires internal visibility with allow-modules [*]; narrower exports need an allow-list-aware catalog") + if len(allowed) != 0 && (len(allowed) != 1 || allowed[0] != "*") { + return "", fmt.Errorf("deployment catalog accepts internal visibility with no allow-modules, or the legacy [*]; a narrower export needs an allow-list-aware catalog") } return "module", nil default: diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go index c2b060876..89d026716 100644 --- a/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go +++ b/module/services/accounts/code/pkg/cataloggen/deployment_topology_test.go @@ -71,15 +71,15 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.Equal(t, string(readFixture(t, "testdata/network-policy.golden.yaml")), string(first.NetworkPolicy), "run: go generate ./pkg/cataloggen") require.Equal(t, string(readFixture(t, "testdata/mesh-policy.golden.yaml")), string(first.MeshPolicy), "run: go generate ./pkg/cataloggen") - require.Len(t, first.Catalog.GetServices(), 8) + require.Len(t, first.Catalog.GetServices(), 9) require.Len(t, first.Catalog.GetInterfaceEndpoints(), 6) - require.Len(t, first.Catalog.GetPublicEgress(), 4) + require.Len(t, first.Catalog.GetPublicEgress(), 5) endpointCount, dependencyCount := 0, 0 for _, service := range first.Catalog.GetServices() { endpointCount += len(service.GetEndpoints()) dependencyCount += len(service.GetDependencies()) } - require.Equal(t, 13, endpointCount) + require.Equal(t, 14, endpointCount) require.Equal(t, 8, dependencyCount) // The accounts REST surface is reachable only through the gateway; the // gateway's REST surface is module-visible because composed modules and @@ -131,7 +131,7 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { require.True(t, accountsConnectExposed) require.True(t, accountsAuthorityExposed) require.True(t, gatewayRESTExposed) - require.Equal(t, 20, strings.Count(string(first.NetworkPolicy), "\nkind: NetworkPolicy\n")) + require.Equal(t, 21, strings.Count(string(first.NetworkPolicy), "\nkind: NetworkPolicy\n")) require.NotContains(t, string(first.NetworkPolicy), "allow-intra-namespace") for _, name := range []string{ "allow-accounts-from-dependents", "allow-auth-gateway-from-dependents", "allow-auth-gateway-to-dependencies", @@ -149,28 +149,49 @@ func TestDeploymentTopologyIsDeterministicAndCurrent(t *testing.T) { // MODULE is the catalog category for every composed module, not a license to // erase a narrower authored allow-list. Legacy Codefly spellings also refuse. +// +// `visibility: internal` WITH NO allow-list has moved OUT of the refused set and +// is now what the manifests author (issue #952). It is the v0.14.0 spelling of +// what `internal` plus `allow-modules: ["*"]` said — core refuses the key on an +// export by name, because an allow-list is derived from the consumers' declared +// dependencies and never written by the module it would grant — so the two mean +// the same thing and map to the same category. +// +// The narrower cases stay refused, which is the half that matters: `[example]` +// names SOME modules, and this catalog has no way to express that, so admitting +// it would silently widen the generated policy to every module. The legacy +// `visibility: module` stays refused too. func TestDeploymentTopologyRefusesUnrepresentableEndpointPolicies(t *testing.T) { catalog := readFixture(t, "../../../generated/service-catalog.json") documents := readDeploymentDocuments(t) for _, policy := range []string{ "visibility: module", "visibility: internal\n allow-modules: [example]", - "visibility: internal", "visibility: public\n allow-modules: [example]", } { - changed := withModule(t, documents, "visibility: internal\n allow-modules: [\"*\"]", policy) + changed := withModule(t, documents, "visibility: internal", policy) _, err := cataloggen.BuildDeploymentArtifacts(catalog, changed) require.Error(t, err, policy) } for _, policy := range []string{ "visibility: module", "visibility: internal\n allow-modules: [example]", - "visibility: internal", } { - changed := withService(t, documents, "accounts", "visibility: internal\n allow-modules: [\"*\"]", policy) + changed := withService(t, documents, "accounts", "visibility: internal", policy) _, err := cataloggen.BuildDeploymentArtifacts(catalog, changed) require.Error(t, err, policy) } + // And the authored spelling BUILDS, so the removals above are not this test + // quietly accepting everything: an unchanged tree must still produce artifacts. + _, err := cataloggen.BuildDeploymentArtifacts(catalog, documents) + require.NoError(t, err, "the authored `internal` with no allow-list must build") + // The legacy wildcard also still builds, because this generator walks every + // composed module's manifests and the fleet has not finished moving. + legacy := withService(t, documents, "accounts", + " - name: connect\n visibility: internal\n", + " - name: connect\n visibility: internal\n allow-modules: [\"*\"]\n") + _, err = cataloggen.BuildDeploymentArtifacts(catalog, legacy) + require.NoError(t, err, "the legacy wildcard must still build while the fleet moves") } // The manifests are the model: a deployment fact lives in the service manifest @@ -210,7 +231,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.NoError(t, loadedModule.ValidateInterface(ctx)) loadedServices, err := loadedModule.LoadServices(ctx) require.NoError(t, err) - require.Len(t, loadedServices, 8) + require.Len(t, loadedServices, 9) moduleDocument := readFixture(t, "../../../../../module.codefly.yaml") var moduleEntry struct { @@ -222,7 +243,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.NoError(t, yaml.Unmarshal(moduleDocument, &module)) _, err = module.Proto(ctx) require.NoError(t, err) - require.Len(t, module.ServiceReferences, 8) + require.Len(t, module.ServiceReferences, 9) for _, reference := range module.ServiceReferences { document := readFixture(t, filepath.Join("../../../../../services", reference.Name, "service.codefly.yaml")) @@ -264,7 +285,7 @@ func TestGeneratedCodeflyAndNetworkManifestsParseStrictly(t *testing.T) { require.False(t, names[document.Metadata.Name], "duplicate NetworkPolicy %s", document.Metadata.Name) names[document.Metadata.Name] = true } - require.Len(t, names, 20) + require.Len(t, names, 21) require.True(t, names["allow-istio-ingress-to-marketing"]) require.True(t, names["allow-istio-ingress-to-frontend"]) require.False(t, names["allow-istio-ingress-to-auth-gateway"]) @@ -389,12 +410,11 @@ func TestMeshPolicyGatesInternalSurfacesByShape(t *testing.T) { const meshIngressPrincipal = "cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" // Every authored route, and the methods that carry internal authority on it. - // A GET on /api/solutions/register is the sidebar's unauthenticated nav poll + // A GET on /api/solutions is the sidebar's viewer-authenticated nav poll // and must stay reachable; a GET on /api/internal/solutions is the internal // detail read and must not. wantRules := map[string][]any{ "/api/internal/solutions": {"GET"}, - "/api/solutions/register": {"DELETE", "POST"}, } decoder := yaml.NewDecoder(strings.NewReader(string(readFixture(t, "testdata/mesh-policy.golden.yaml")))) @@ -449,7 +469,7 @@ func TestMeshPolicyGatesInternalSurfacesByShape(t *testing.T) { require.True(t, ok) paths := operation["paths"].([]any) // Istio does not merge duplicate slashes by default, so the exact - // path alone would let //api/solutions/register reach the handler. + // path alone would let //api/internal/solutions reach the handler. require.Len(t, paths, 2, "each route is matched exactly and as a suffix") path := paths[1].(string) require.Equal(t, "*"+path, paths[0]) @@ -543,15 +563,15 @@ func TestDeploymentTopologyRejectsUnsafeOrIncompleteManifests(t *testing.T) { require.ErrorContains(t, err, "service entry references unknown service") _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withService(t, documents, "frontend", " - DELETE\n - POST", " - POST\n - DELETE")) - require.ErrorContains(t, err, "internal HTTP route \"/api/solutions/register\" methods are invalid or unsorted") + withService(t, documents, "frontend", " - GET", " - POST\n - GET")) + require.ErrorContains(t, err, "internal HTTP route \"/api/internal/solutions\" methods are invalid or unsorted") _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withService(t, documents, "frontend", " - path: /api/solutions/register", " - path: api/solutions/register")) + withService(t, documents, "frontend", " - path: /api/internal/solutions", " - path: api/internal/solutions")) require.ErrorContains(t, err, "internal HTTP routes are invalid or unsorted") _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withService(t, documents, "frontend", " methods:\n - DELETE\n - POST", " methods: []")) + withService(t, documents, "frontend", " methods:\n - GET", " methods: []")) require.ErrorContains(t, err, "declares no methods") // A path policy on a service that speaks TCP matches nothing that will ever @@ -779,10 +799,10 @@ func TestDeploymentTopologyRequiresTheModuleAuthorityEndpoint(t *testing.T) { documents := readDeploymentDocuments(t) _, err := cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withModule(t, documents, " - service: accounts\n endpoint: authority\n visibility: internal\n allow-modules: [\"*\"]\n", "")) + withModule(t, documents, " - service: accounts\n endpoint: authority\n visibility: internal\n", "")) require.ErrorContains(t, err, "module interface must export accounts/authority") _, err = cataloggen.BuildDeploymentArtifacts(serviceCatalog, - withService(t, documents, "accounts", " - name: authority\n api: grpc\n visibility: internal\n allow-modules: [\"*\"]\n", " - name: authority\n api: rest\n visibility: internal\n allow-modules: [\"*\"]\n")) + withService(t, documents, "accounts", " - name: authority\n api: grpc\n visibility: internal\n", " - name: authority\n api: rest\n visibility: internal\n")) require.ErrorContains(t, err, "must be a gRPC endpoint at module visibility") } diff --git a/module/services/accounts/code/pkg/cataloggen/deployment_visibility_pin_test.go b/module/services/accounts/code/pkg/cataloggen/deployment_visibility_pin_test.go new file mode 100644 index 000000000..e214bac20 --- /dev/null +++ b/module/services/accounts/code/pkg/cataloggen/deployment_visibility_pin_test.go @@ -0,0 +1,52 @@ +package cataloggen + +import "testing" + +// The OTHER HALF OF A PIN. The module agent at the repository root holds the same +// projection as endpointVisibility (topology_manifests.go), in a separate Go module +// so neither side can import the other, and topology_visibility_test.go carries the +// matching table. +// +// This test exists because the two drifted and the symptom was remote from the +// cause: when the manifests stopped authoring `allow-modules: ["*"]`, this copy +// accepted the new spelling and the root copy still demanded the wildcard, so the +// red surfaced as TestShippedModuleAuthorityPortIsAllocatedNotDeclared failing in +// the `Codefly SDK boundary` gate — a test about PORTS, on a manifest this package +// was perfectly happy with. A row added here is owed to the root table too. +func TestManifestCatalogVisibilityMatchesTheRootProjection(t *testing.T) { + for _, tc := range []struct { + visibility, location string + allowed []string + want string + }{ + // `internal` with NO allow-list is what the manifests author at core + // v0.14.0, and it maps to the same category the legacy wildcard did. + {"internal", "", nil, "module"}, + {"internal", "", []string{}, "module"}, + {"internal", "", []string{"*"}, "module"}, + {"private", "external", nil, "external"}, + {"public", "", nil, "public"}, + {"", "", nil, "private"}, + // A NARROWER list still refuses: this catalog cannot express "some + // modules", so admitting one would silently widen the generated policy to + // every module. + {"internal", "", []string{"example"}, ""}, + {"internal", "", []string{"*", "example"}, ""}, + {"public", "", []string{"example"}, ""}, + {"module", "", nil, ""}, + {"external", "", nil, ""}, + {"private", "elsewhere", nil, ""}, + {"internal", "external", []string{"example"}, ""}, + } { + got, err := manifestCatalogVisibility(tc.visibility, tc.location, tc.allowed) + if tc.want == "" { + if err == nil { + t.Errorf("accepted unsupported policy %#v as %q", tc, got) + } + continue + } + if err != nil || got != tc.want { + t.Errorf("policy %#v: got %q, %v", tc, got, err) + } + } +} diff --git a/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go b/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go index ea4b12c1c..ebd2ac49c 100644 --- a/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go +++ b/module/services/accounts/code/pkg/cataloggen/gateway_routes_test.go @@ -50,7 +50,7 @@ func TestGatewayRouteCatalogCompilationAndParity(t *testing.T) { require.Equal(t, "accounts", legacy.GetOwner().GetService()) require.Equal(t, "connect", legacy.GetUpstreamEndpoint()) - renamed := withService(t, topologyDocuments, "accounts", " - name: connect\n visibility: internal\n allow-modules: [\"*\"]\n", " - name: connect-api\n api: connect\n visibility: internal\n allow-modules: [\"*\"]\n") + renamed := withService(t, topologyDocuments, "accounts", " - name: connect\n visibility: internal\n", " - name: connect-api\n api: connect\n visibility: internal\n") renamed = withService(t, renamed, "auth-gateway", " - name: connect\n", " - name: connect-api\n") renamed = withModule(t, renamed, " endpoint: connect\n", " endpoint: connect-api\n") renamedRoutes, err := cataloggen.BuildGatewayRouteCatalog(serviceDocument, bindingDocument, renamed) @@ -101,7 +101,7 @@ func TestGatewayRouteValidationRejectsUnsafeDrift(t *testing.T) { wrongSchema.SchemaVersion = "saas.gateway.routes.v2" require.ErrorContains(t, cataloggen.ValidateGatewayRouteCatalog(wrongSchema), "unsupported") - badEndpoint := withService(t, topologyDocuments, "accounts", " - name: connect\n visibility: internal\n allow-modules: [\"*\"]\n", " - name: connect\n api: connect;raw\n visibility: internal\n allow-modules: [\"*\"]\n") + badEndpoint := withService(t, topologyDocuments, "accounts", " - name: connect\n visibility: internal\n", " - name: connect\n api: connect;raw\n visibility: internal\n") _, err = cataloggen.BuildGatewayRouteCatalog(serviceDocument, bindingDocument, badEndpoint) require.ErrorContains(t, err, "Codefly API") diff --git a/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml b/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml index 65ffc2dbf..f89f2f9bb 100644 --- a/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml +++ b/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml @@ -31,6 +31,7 @@ spec: - /saas.accounts.v1.APIKeyService/ValidateAPIKey - /saas.accounts.v1.ClientRegistryService/ListRegisteredClients - /saas.accounts.v1.IdentityService/ResolveIdentity + - /saas.accounts.v1.InstallationService/ListInstallations - /saas.accounts.v1.ModuleCapabilitiesService/AckJob - /saas.accounts.v1.ModuleCapabilitiesService/ApproveExecutableArtifact - /saas.accounts.v1.ModuleCapabilitiesService/AuthorizeExecutableArtifact @@ -73,9 +74,9 @@ spec: - /saas.accounts.v1.PrincipalService/EnableAgentPrincipal - /saas.accounts.v1.PrincipalService/GetAgentPrincipal - /saas.accounts.v1.PrincipalService/GetPrincipal - - /saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration + - /saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements + - /saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings - /saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations - - /saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration - /saas.accounts.v1.UsageService/ConsumeUsage - /saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead - /saas.accounts.v1.WorkContextService/CheckAuthorizationRevision @@ -105,18 +106,6 @@ spec: - "/api/internal/solutions" methods: - GET - - from: - - source: - notPrincipals: - - cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account - to: - - operation: - paths: - - "*/api/solutions/register" - - "/api/solutions/register" - methods: - - DELETE - - POST --- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway diff --git a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml index f2c9e20af..3acdbfc7b 100644 --- a/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml +++ b/module/services/accounts/code/pkg/cataloggen/testdata/network-policy.golden.yaml @@ -540,6 +540,58 @@ spec: --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy +metadata: + name: allow-policy-log-public-egress + namespace: saas-starter +spec: + podSelector: + matchLabels: + app: policy-log + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 0.0.0.0/8 + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.0.0.0/24 + - 192.0.2.0/24 + - 192.31.196.0/24 + - 192.52.193.0/24 + - 192.88.99.0/24 + - 192.168.0.0/16 + - 192.175.48.0/24 + - 198.18.0.0/15 + - 198.51.100.0/24 + - 203.0.113.0/24 + - 224.0.0.0/4 + - 240.0.0.0/4 + - ipBlock: + cidr: ::/0 + except: + - ::/128 + - ::1/128 + - 64:ff9b::/96 + - 64:ff9b:1::/48 + - 100::/64 + - 2001::/23 + - 2001:db8::/32 + - 2002::/16 + - fc00::/7 + - fe80::/10 + - ff00::/8 + ports: + - protocol: TCP + port: 443 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy metadata: name: allow-telemetry-public-egress namespace: saas-starter diff --git a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go index d832d83fb..a5d7fb1f3 100644 --- a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go +++ b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go @@ -171,9 +171,9 @@ var published = [...]PublishedEvent{ {Type: "saas.scope.revoked", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.session.revoked", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.settings.updated", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.solution.host_binding_applied", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.solution.registration_deleted", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.solution.registration_minted", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, - {Type: "saas.solution.registration_updated", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.sso.disabled", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.sso.setup.started", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.team.created", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go index 1def84ab8..2cd825965 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/installations.connect.go @@ -47,6 +47,12 @@ const ( // InstallationServiceGetInstallationProcedure is the fully-qualified name of the // InstallationService's GetInstallation RPC. InstallationServiceGetInstallationProcedure = "/saas.accounts.v1.InstallationService/GetInstallation" + // InstallationServiceListInstallationsProcedure is the fully-qualified name of the + // InstallationService's ListInstallations RPC. + InstallationServiceListInstallationsProcedure = "/saas.accounts.v1.InstallationService/ListInstallations" + // InstallationServiceListAvailableSolutionsProcedure is the fully-qualified name of the + // InstallationService's ListAvailableSolutions RPC. + InstallationServiceListAvailableSolutionsProcedure = "/saas.accounts.v1.InstallationService/ListAvailableSolutions" ) // InstallationServiceClient is a client for the saas.accounts.v1.InstallationService service. @@ -70,6 +76,30 @@ type InstallationServiceClient interface { // can see when an installation has gone unhealthy (owner offboarded, agent // disabled, grant expired) before the next headless task fails closed. GetInstallation(context.Context, *connect.Request[v1.GetInstallationRequest]) (*connect.Response[v1.GetInstallationResponse], error) + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + ListInstallations(context.Context, *connect.Request[v1.ListInstallationsRequest]) (*connect.Response[v1.ListInstallationsResponse], error) + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + ListAvailableSolutions(context.Context, *connect.Request[v1.ListAvailableSolutionsRequest]) (*connect.Response[v1.ListAvailableSolutionsResponse], error) } // NewInstallationServiceClient constructs a client for the saas.accounts.v1.InstallationService @@ -107,6 +137,18 @@ func NewInstallationServiceClient(httpClient connect.HTTPClient, baseURL string, connect.WithSchema(installationServiceMethods.ByName("GetInstallation")), connect.WithClientOptions(opts...), ), + listInstallations: connect.NewClient[v1.ListInstallationsRequest, v1.ListInstallationsResponse]( + httpClient, + baseURL+InstallationServiceListInstallationsProcedure, + connect.WithSchema(installationServiceMethods.ByName("ListInstallations")), + connect.WithClientOptions(opts...), + ), + listAvailableSolutions: connect.NewClient[v1.ListAvailableSolutionsRequest, v1.ListAvailableSolutionsResponse]( + httpClient, + baseURL+InstallationServiceListAvailableSolutionsProcedure, + connect.WithSchema(installationServiceMethods.ByName("ListAvailableSolutions")), + connect.WithClientOptions(opts...), + ), } } @@ -116,6 +158,8 @@ type installationServiceClient struct { uninstallSolution *connect.Client[v1.UninstallSolutionRequest, emptypb.Empty] transferInstallationOwnership *connect.Client[v1.TransferInstallationOwnershipRequest, v1.Installation] getInstallation *connect.Client[v1.GetInstallationRequest, v1.GetInstallationResponse] + listInstallations *connect.Client[v1.ListInstallationsRequest, v1.ListInstallationsResponse] + listAvailableSolutions *connect.Client[v1.ListAvailableSolutionsRequest, v1.ListAvailableSolutionsResponse] } // InstallSolution calls saas.accounts.v1.InstallationService.InstallSolution. @@ -139,6 +183,16 @@ func (c *installationServiceClient) GetInstallation(ctx context.Context, req *co return c.getInstallation.CallUnary(ctx, req) } +// ListInstallations calls saas.accounts.v1.InstallationService.ListInstallations. +func (c *installationServiceClient) ListInstallations(ctx context.Context, req *connect.Request[v1.ListInstallationsRequest]) (*connect.Response[v1.ListInstallationsResponse], error) { + return c.listInstallations.CallUnary(ctx, req) +} + +// ListAvailableSolutions calls saas.accounts.v1.InstallationService.ListAvailableSolutions. +func (c *installationServiceClient) ListAvailableSolutions(ctx context.Context, req *connect.Request[v1.ListAvailableSolutionsRequest]) (*connect.Response[v1.ListAvailableSolutionsResponse], error) { + return c.listAvailableSolutions.CallUnary(ctx, req) +} + // InstallationServiceHandler is an implementation of the saas.accounts.v1.InstallationService // service. type InstallationServiceHandler interface { @@ -161,6 +215,30 @@ type InstallationServiceHandler interface { // can see when an installation has gone unhealthy (owner offboarded, agent // disabled, grant expired) before the next headless task fails closed. GetInstallation(context.Context, *connect.Request[v1.GetInstallationRequest]) (*connect.Response[v1.GetInstallationResponse], error) + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + ListInstallations(context.Context, *connect.Request[v1.ListInstallationsRequest]) (*connect.Response[v1.ListInstallationsResponse], error) + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + ListAvailableSolutions(context.Context, *connect.Request[v1.ListAvailableSolutionsRequest]) (*connect.Response[v1.ListAvailableSolutionsResponse], error) } // NewInstallationServiceHandler builds an HTTP handler from the service implementation. It returns @@ -194,6 +272,18 @@ func NewInstallationServiceHandler(svc InstallationServiceHandler, opts ...conne connect.WithSchema(installationServiceMethods.ByName("GetInstallation")), connect.WithHandlerOptions(opts...), ) + installationServiceListInstallationsHandler := connect.NewUnaryHandler( + InstallationServiceListInstallationsProcedure, + svc.ListInstallations, + connect.WithSchema(installationServiceMethods.ByName("ListInstallations")), + connect.WithHandlerOptions(opts...), + ) + installationServiceListAvailableSolutionsHandler := connect.NewUnaryHandler( + InstallationServiceListAvailableSolutionsProcedure, + svc.ListAvailableSolutions, + connect.WithSchema(installationServiceMethods.ByName("ListAvailableSolutions")), + connect.WithHandlerOptions(opts...), + ) return "/saas.accounts.v1.InstallationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case InstallationServiceInstallSolutionProcedure: @@ -204,6 +294,10 @@ func NewInstallationServiceHandler(svc InstallationServiceHandler, opts ...conne installationServiceTransferInstallationOwnershipHandler.ServeHTTP(w, r) case InstallationServiceGetInstallationProcedure: installationServiceGetInstallationHandler.ServeHTTP(w, r) + case InstallationServiceListInstallationsProcedure: + installationServiceListInstallationsHandler.ServeHTTP(w, r) + case InstallationServiceListAvailableSolutionsProcedure: + installationServiceListAvailableSolutionsHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -228,3 +322,11 @@ func (UnimplementedInstallationServiceHandler) TransferInstallationOwnership(con func (UnimplementedInstallationServiceHandler) GetInstallation(context.Context, *connect.Request[v1.GetInstallationRequest]) (*connect.Response[v1.GetInstallationResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.InstallationService.GetInstallation is not implemented")) } + +func (UnimplementedInstallationServiceHandler) ListInstallations(context.Context, *connect.Request[v1.ListInstallationsRequest]) (*connect.Response[v1.ListInstallationsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.InstallationService.ListInstallations is not implemented")) +} + +func (UnimplementedInstallationServiceHandler) ListAvailableSolutions(context.Context, *connect.Request[v1.ListAvailableSolutionsRequest]) (*connect.Response[v1.ListAvailableSolutionsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.InstallationService.ListAvailableSolutions is not implemented")) +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_entitlements_service.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_entitlements_service.connect.go new file mode 100644 index 000000000..4ac5d780f --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_entitlements_service.connect.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: saas/accounts/v1/solution_entitlements_service.proto + +package accountsv1connect + +import ( + v1 "accounts/pkg/gen/saas/accounts/v1" + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // SolutionEntitlementServiceName is the fully-qualified name of the SolutionEntitlementService + // service. + SolutionEntitlementServiceName = "saas.accounts.v1.SolutionEntitlementService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // SolutionEntitlementServiceListSolutionEntitlementsProcedure is the fully-qualified name of the + // SolutionEntitlementService's ListSolutionEntitlements RPC. + SolutionEntitlementServiceListSolutionEntitlementsProcedure = "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" +) + +// SolutionEntitlementServiceClient is a client for the saas.accounts.v1.SolutionEntitlementService +// service. +type SolutionEntitlementServiceClient interface { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + ListSolutionEntitlements(context.Context, *connect.Request[v1.ListSolutionEntitlementsRequest]) (*connect.Response[v1.ListSolutionEntitlementsResponse], error) +} + +// NewSolutionEntitlementServiceClient constructs a client for the +// saas.accounts.v1.SolutionEntitlementService service. By default, it uses the Connect protocol +// with the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed requests. To +// use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or connect.WithGRPCWeb() +// options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewSolutionEntitlementServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SolutionEntitlementServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + solutionEntitlementServiceMethods := v1.File_saas_accounts_v1_solution_entitlements_service_proto.Services().ByName("SolutionEntitlementService").Methods() + return &solutionEntitlementServiceClient{ + listSolutionEntitlements: connect.NewClient[v1.ListSolutionEntitlementsRequest, v1.ListSolutionEntitlementsResponse]( + httpClient, + baseURL+SolutionEntitlementServiceListSolutionEntitlementsProcedure, + connect.WithSchema(solutionEntitlementServiceMethods.ByName("ListSolutionEntitlements")), + connect.WithClientOptions(opts...), + ), + } +} + +// solutionEntitlementServiceClient implements SolutionEntitlementServiceClient. +type solutionEntitlementServiceClient struct { + listSolutionEntitlements *connect.Client[v1.ListSolutionEntitlementsRequest, v1.ListSolutionEntitlementsResponse] +} + +// ListSolutionEntitlements calls +// saas.accounts.v1.SolutionEntitlementService.ListSolutionEntitlements. +func (c *solutionEntitlementServiceClient) ListSolutionEntitlements(ctx context.Context, req *connect.Request[v1.ListSolutionEntitlementsRequest]) (*connect.Response[v1.ListSolutionEntitlementsResponse], error) { + return c.listSolutionEntitlements.CallUnary(ctx, req) +} + +// SolutionEntitlementServiceHandler is an implementation of the +// saas.accounts.v1.SolutionEntitlementService service. +type SolutionEntitlementServiceHandler interface { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + ListSolutionEntitlements(context.Context, *connect.Request[v1.ListSolutionEntitlementsRequest]) (*connect.Response[v1.ListSolutionEntitlementsResponse], error) +} + +// NewSolutionEntitlementServiceHandler builds an HTTP handler from the service implementation. It +// returns the path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewSolutionEntitlementServiceHandler(svc SolutionEntitlementServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + solutionEntitlementServiceMethods := v1.File_saas_accounts_v1_solution_entitlements_service_proto.Services().ByName("SolutionEntitlementService").Methods() + solutionEntitlementServiceListSolutionEntitlementsHandler := connect.NewUnaryHandler( + SolutionEntitlementServiceListSolutionEntitlementsProcedure, + svc.ListSolutionEntitlements, + connect.WithSchema(solutionEntitlementServiceMethods.ByName("ListSolutionEntitlements")), + connect.WithHandlerOptions(opts...), + ) + return "/saas.accounts.v1.SolutionEntitlementService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case SolutionEntitlementServiceListSolutionEntitlementsProcedure: + solutionEntitlementServiceListSolutionEntitlementsHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedSolutionEntitlementServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedSolutionEntitlementServiceHandler struct{} + +func (UnimplementedSolutionEntitlementServiceHandler) ListSolutionEntitlements(context.Context, *connect.Request[v1.ListSolutionEntitlementsRequest]) (*connect.Response[v1.ListSolutionEntitlementsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.SolutionEntitlementService.ListSolutionEntitlements is not implemented")) +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_registry_service.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_registry_service.connect.go index 263995b4b..c05c9ff76 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_registry_service.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_registry_service.connect.go @@ -34,12 +34,9 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // SolutionRegistryServicePutSolutionRegistrationProcedure is the fully-qualified name of the - // SolutionRegistryService's PutSolutionRegistration RPC. - SolutionRegistryServicePutSolutionRegistrationProcedure = "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" - // SolutionRegistryServiceDeleteSolutionRegistrationProcedure is the fully-qualified name of the - // SolutionRegistryService's DeleteSolutionRegistration RPC. - SolutionRegistryServiceDeleteSolutionRegistrationProcedure = "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration" + // SolutionRegistryServiceListSolutionHostBindingsProcedure is the fully-qualified name of the + // SolutionRegistryService's ListSolutionHostBindings RPC. + SolutionRegistryServiceListSolutionHostBindingsProcedure = "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings" // SolutionRegistryServiceListSolutionRegistrationsProcedure is the fully-qualified name of the // SolutionRegistryService's ListSolutionRegistrations RPC. SolutionRegistryServiceListSolutionRegistrationsProcedure = "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" @@ -48,12 +45,12 @@ const ( // SolutionRegistryServiceClient is a client for the saas.accounts.v1.SolutionRegistryService // service. type SolutionRegistryServiceClient interface { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - PutSolutionRegistration(context.Context, *connect.Request[v1.PutSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - DeleteSolutionRegistration(context.Context, *connect.Request[v1.DeleteSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + ListSolutionHostBindings(context.Context, *connect.Request[v1.ListSolutionHostBindingsRequest]) (*connect.Response[v1.ListSolutionHostBindingsResponse], error) // ListSolutionRegistrations returns the whole registry so a restarted or // lagging replica can rebuild its routing cache from authoritative state. ListSolutionRegistrations(context.Context, *connect.Request[v1.ListSolutionRegistrationsRequest]) (*connect.Response[v1.ListSolutionRegistrationsResponse], error) @@ -70,16 +67,10 @@ func NewSolutionRegistryServiceClient(httpClient connect.HTTPClient, baseURL str baseURL = strings.TrimRight(baseURL, "/") solutionRegistryServiceMethods := v1.File_saas_accounts_v1_solution_registry_service_proto.Services().ByName("SolutionRegistryService").Methods() return &solutionRegistryServiceClient{ - putSolutionRegistration: connect.NewClient[v1.PutSolutionRegistrationRequest, v1.SolutionRegistration]( + listSolutionHostBindings: connect.NewClient[v1.ListSolutionHostBindingsRequest, v1.ListSolutionHostBindingsResponse]( httpClient, - baseURL+SolutionRegistryServicePutSolutionRegistrationProcedure, - connect.WithSchema(solutionRegistryServiceMethods.ByName("PutSolutionRegistration")), - connect.WithClientOptions(opts...), - ), - deleteSolutionRegistration: connect.NewClient[v1.DeleteSolutionRegistrationRequest, v1.SolutionRegistration]( - httpClient, - baseURL+SolutionRegistryServiceDeleteSolutionRegistrationProcedure, - connect.WithSchema(solutionRegistryServiceMethods.ByName("DeleteSolutionRegistration")), + baseURL+SolutionRegistryServiceListSolutionHostBindingsProcedure, + connect.WithSchema(solutionRegistryServiceMethods.ByName("ListSolutionHostBindings")), connect.WithClientOptions(opts...), ), listSolutionRegistrations: connect.NewClient[v1.ListSolutionRegistrationsRequest, v1.ListSolutionRegistrationsResponse]( @@ -93,20 +84,13 @@ func NewSolutionRegistryServiceClient(httpClient connect.HTTPClient, baseURL str // solutionRegistryServiceClient implements SolutionRegistryServiceClient. type solutionRegistryServiceClient struct { - putSolutionRegistration *connect.Client[v1.PutSolutionRegistrationRequest, v1.SolutionRegistration] - deleteSolutionRegistration *connect.Client[v1.DeleteSolutionRegistrationRequest, v1.SolutionRegistration] - listSolutionRegistrations *connect.Client[v1.ListSolutionRegistrationsRequest, v1.ListSolutionRegistrationsResponse] -} - -// PutSolutionRegistration calls saas.accounts.v1.SolutionRegistryService.PutSolutionRegistration. -func (c *solutionRegistryServiceClient) PutSolutionRegistration(ctx context.Context, req *connect.Request[v1.PutSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) { - return c.putSolutionRegistration.CallUnary(ctx, req) + listSolutionHostBindings *connect.Client[v1.ListSolutionHostBindingsRequest, v1.ListSolutionHostBindingsResponse] + listSolutionRegistrations *connect.Client[v1.ListSolutionRegistrationsRequest, v1.ListSolutionRegistrationsResponse] } -// DeleteSolutionRegistration calls -// saas.accounts.v1.SolutionRegistryService.DeleteSolutionRegistration. -func (c *solutionRegistryServiceClient) DeleteSolutionRegistration(ctx context.Context, req *connect.Request[v1.DeleteSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) { - return c.deleteSolutionRegistration.CallUnary(ctx, req) +// ListSolutionHostBindings calls saas.accounts.v1.SolutionRegistryService.ListSolutionHostBindings. +func (c *solutionRegistryServiceClient) ListSolutionHostBindings(ctx context.Context, req *connect.Request[v1.ListSolutionHostBindingsRequest]) (*connect.Response[v1.ListSolutionHostBindingsResponse], error) { + return c.listSolutionHostBindings.CallUnary(ctx, req) } // ListSolutionRegistrations calls @@ -118,12 +102,12 @@ func (c *solutionRegistryServiceClient) ListSolutionRegistrations(ctx context.Co // SolutionRegistryServiceHandler is an implementation of the // saas.accounts.v1.SolutionRegistryService service. type SolutionRegistryServiceHandler interface { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - PutSolutionRegistration(context.Context, *connect.Request[v1.PutSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - DeleteSolutionRegistration(context.Context, *connect.Request[v1.DeleteSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + ListSolutionHostBindings(context.Context, *connect.Request[v1.ListSolutionHostBindingsRequest]) (*connect.Response[v1.ListSolutionHostBindingsResponse], error) // ListSolutionRegistrations returns the whole registry so a restarted or // lagging replica can rebuild its routing cache from authoritative state. ListSolutionRegistrations(context.Context, *connect.Request[v1.ListSolutionRegistrationsRequest]) (*connect.Response[v1.ListSolutionRegistrationsResponse], error) @@ -136,16 +120,10 @@ type SolutionRegistryServiceHandler interface { // and JSON codecs. They also support gzip compression. func NewSolutionRegistryServiceHandler(svc SolutionRegistryServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { solutionRegistryServiceMethods := v1.File_saas_accounts_v1_solution_registry_service_proto.Services().ByName("SolutionRegistryService").Methods() - solutionRegistryServicePutSolutionRegistrationHandler := connect.NewUnaryHandler( - SolutionRegistryServicePutSolutionRegistrationProcedure, - svc.PutSolutionRegistration, - connect.WithSchema(solutionRegistryServiceMethods.ByName("PutSolutionRegistration")), - connect.WithHandlerOptions(opts...), - ) - solutionRegistryServiceDeleteSolutionRegistrationHandler := connect.NewUnaryHandler( - SolutionRegistryServiceDeleteSolutionRegistrationProcedure, - svc.DeleteSolutionRegistration, - connect.WithSchema(solutionRegistryServiceMethods.ByName("DeleteSolutionRegistration")), + solutionRegistryServiceListSolutionHostBindingsHandler := connect.NewUnaryHandler( + SolutionRegistryServiceListSolutionHostBindingsProcedure, + svc.ListSolutionHostBindings, + connect.WithSchema(solutionRegistryServiceMethods.ByName("ListSolutionHostBindings")), connect.WithHandlerOptions(opts...), ) solutionRegistryServiceListSolutionRegistrationsHandler := connect.NewUnaryHandler( @@ -156,10 +134,8 @@ func NewSolutionRegistryServiceHandler(svc SolutionRegistryServiceHandler, opts ) return "/saas.accounts.v1.SolutionRegistryService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case SolutionRegistryServicePutSolutionRegistrationProcedure: - solutionRegistryServicePutSolutionRegistrationHandler.ServeHTTP(w, r) - case SolutionRegistryServiceDeleteSolutionRegistrationProcedure: - solutionRegistryServiceDeleteSolutionRegistrationHandler.ServeHTTP(w, r) + case SolutionRegistryServiceListSolutionHostBindingsProcedure: + solutionRegistryServiceListSolutionHostBindingsHandler.ServeHTTP(w, r) case SolutionRegistryServiceListSolutionRegistrationsProcedure: solutionRegistryServiceListSolutionRegistrationsHandler.ServeHTTP(w, r) default: @@ -171,12 +147,8 @@ func NewSolutionRegistryServiceHandler(svc SolutionRegistryServiceHandler, opts // UnimplementedSolutionRegistryServiceHandler returns CodeUnimplemented from all methods. type UnimplementedSolutionRegistryServiceHandler struct{} -func (UnimplementedSolutionRegistryServiceHandler) PutSolutionRegistration(context.Context, *connect.Request[v1.PutSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.SolutionRegistryService.PutSolutionRegistration is not implemented")) -} - -func (UnimplementedSolutionRegistryServiceHandler) DeleteSolutionRegistration(context.Context, *connect.Request[v1.DeleteSolutionRegistrationRequest]) (*connect.Response[v1.SolutionRegistration], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.SolutionRegistryService.DeleteSolutionRegistration is not implemented")) +func (UnimplementedSolutionRegistryServiceHandler) ListSolutionHostBindings(context.Context, *connect.Request[v1.ListSolutionHostBindingsRequest]) (*connect.Response[v1.ListSolutionHostBindingsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.SolutionRegistryService.ListSolutionHostBindings is not implemented")) } func (UnimplementedSolutionRegistryServiceHandler) ListSolutionRegistrations(context.Context, *connect.Request[v1.ListSolutionRegistrationsRequest]) (*connect.Response[v1.ListSolutionRegistrationsResponse], error) { diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go index 10fa9cdea..b1fb6b051 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go @@ -150,15 +150,34 @@ type Installation struct { Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` OrgId string `protobuf:"bytes,2,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` AgentPrincipalId string `protobuf:"bytes,3,opt,name=agent_principal_id,json=agentPrincipalId,proto3" json:"agent_principal_id,omitempty"` - SolutionIdentifier string `protobuf:"bytes,4,opt,name=solution_identifier,json=solutionIdentifier,proto3" json:"solution_identifier,omitempty"` OwnerPrincipalId string `protobuf:"bytes,5,opt,name=owner_principal_id,json=ownerPrincipalId,proto3" json:"owner_principal_id,omitempty"` CoOwnerPrincipalIds []string `protobuf:"bytes,6,rep,name=co_owner_principal_ids,json=coOwnerPrincipalIds,proto3" json:"co_owner_principal_ids,omitempty"` RootScopeNodeId string `protobuf:"bytes,7,opt,name=root_scope_node_id,json=rootScopeNodeId,proto3" json:"root_scope_node_id,omitempty"` Status InstallationStatus `protobuf:"varint,8,opt,name=status,proto3,enum=saas.accounts.v1.InstallationStatus" json:"status,omitempty"` CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` RevokedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=revoked_at,json=revokedAt,proto3,oneof" json:"revoked_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The immutable solution target this installation was consented to: one + // continuous period of one binding's presence on this host. It is never + // reused, so a replacement binding — even one claiming the same route alias — + // is a different target and inherits nothing. + // + // Deliberately NOT accompanied by the route alias. The alias is a property of + // the target that a later generation may move, and resolving it here would + // mean joining the host's presence state into a request-path listing — which + // would require granting request traffic read over every organisation's + // presence to answer about one. A consumer that needs the alias asks + // ListAvailableSolutions, which serves accepted applied state, and joins on + // this id. + // + // Empty only on a revoked historical row whose target could not be + // established at the cutover; an active installation always names one. + TargetId string `protobuf:"bytes,11,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + // Why a revoked installation was revoked, when it was not an administrator + // uninstalling: the installation-identity cutover, or a tenancy narrowing that + // withdrew this organisation. Empty on an active row. + RevokedReason string `protobuf:"bytes,12,opt,name=revoked_reason,json=revokedReason,proto3" json:"revoked_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Installation) Reset() { @@ -212,13 +231,6 @@ func (x *Installation) GetAgentPrincipalId() string { return "" } -func (x *Installation) GetSolutionIdentifier() string { - if x != nil { - return x.SolutionIdentifier - } - return "" -} - func (x *Installation) GetOwnerPrincipalId() string { if x != nil { return x.OwnerPrincipalId @@ -261,13 +273,26 @@ func (x *Installation) GetRevokedAt() *timestamppb.Timestamp { return nil } +func (x *Installation) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *Installation) GetRevokedReason() string { + if x != nil { + return x.RevokedReason + } + return "" +} + type InstallSolutionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` // Canonical "publisher/name:version" of the solution's agent. The agent // Principal is created idempotently per version. - AgentIdentifier string `protobuf:"bytes,2,opt,name=agent_identifier,json=agentIdentifier,proto3" json:"agent_identifier,omitempty"` - SolutionIdentifier string `protobuf:"bytes,3,opt,name=solution_identifier,json=solutionIdentifier,proto3" json:"solution_identifier,omitempty"` + AgentIdentifier string `protobuf:"bytes,2,opt,name=agent_identifier,json=agentIdentifier,proto3" json:"agent_identifier,omitempty"` // Human display name for the agent principal. Empty defaults to the identifier. DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` // Human-readable display label for the solution's authority-root scope node @@ -288,8 +313,16 @@ type InstallSolutionRequest struct { // Empty = unrestricted. AllowedAudiences []string `protobuf:"bytes,10,rep,name=allowed_audiences,json=allowedAudiences,proto3" json:"allowed_audiences,omitempty"` AllowedScopes []string `protobuf:"bytes,11,rep,name=allowed_scopes,json=allowedScopes,proto3" json:"allowed_scopes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The solution target being installed, from ListAvailableSolutions. Required. + // + // An installer chooses an identity, never an address. The target must be LIVE + // and ACCEPTED when the install lands: installing a withdrawn target would + // record consent to a presence that already ended, and installing a target + // whose newest generation was refused would record consent to a release this + // host never admitted. + TargetId string `protobuf:"bytes,12,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InstallSolutionRequest) Reset() { @@ -336,13 +369,6 @@ func (x *InstallSolutionRequest) GetAgentIdentifier() string { return "" } -func (x *InstallSolutionRequest) GetSolutionIdentifier() string { - if x != nil { - return x.SolutionIdentifier - } - return "" -} - func (x *InstallSolutionRequest) GetDisplayName() string { if x != nil { return x.DisplayName @@ -392,6 +418,13 @@ func (x *InstallSolutionRequest) GetAllowedScopes() []string { return nil } +func (x *InstallSolutionRequest) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + type UninstallSolutionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` @@ -619,16 +652,440 @@ func (x *GetInstallationResponse) GetHealth() InstallationHealth { return InstallationHealth_INSTALLATION_HEALTH_UNSPECIFIED } +// ListInstallationsRequest asks what one organization has installed. Every other +// RPC on this service names a single installation, so until this existed nothing +// could answer "what has this org installed?" — which is the authority read a +// per-organization solution projection is built on (issue #949). +type ListInstallationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + // Restrict to one lifecycle status. UNSPECIFIED returns every status, so a + // caller that must see only live installs asks for ACTIVE explicitly rather + // than leaning on a default that would silently widen if the enum grew. + Status InstallationStatus `protobuf:"varint,2,opt,name=status,proto3,enum=saas.accounts.v1.InstallationStatus" json:"status,omitempty"` + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque cursor from a previous response's next_page_token; empty for page one. + PageToken string `protobuf:"bytes,4,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListInstallationsRequest) Reset() { + *x = ListInstallationsRequest{} + mi := &file_saas_accounts_v1_installations_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListInstallationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListInstallationsRequest) ProtoMessage() {} + +func (x *ListInstallationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_installations_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListInstallationsRequest.ProtoReflect.Descriptor instead. +func (*ListInstallationsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_installations_proto_rawDescGZIP(), []int{6} +} + +func (x *ListInstallationsRequest) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *ListInstallationsRequest) GetStatus() InstallationStatus { + if x != nil { + return x.Status + } + return InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED +} + +func (x *ListInstallationsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListInstallationsRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +// InstallationSummary is one row of the listing: the installation, plus the health +// resolved live beside it exactly as GetInstallation resolves it for one. Health +// travels with the row because a consumer that renders an installed solution as +// unavailable would otherwise need one GetInstallation per installation. +type InstallationSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Installation *Installation `protobuf:"bytes,1,opt,name=installation,proto3" json:"installation,omitempty"` + Health InstallationHealth `protobuf:"varint,2,opt,name=health,proto3,enum=saas.accounts.v1.InstallationHealth" json:"health,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallationSummary) Reset() { + *x = InstallationSummary{} + mi := &file_saas_accounts_v1_installations_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallationSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallationSummary) ProtoMessage() {} + +func (x *InstallationSummary) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_installations_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallationSummary.ProtoReflect.Descriptor instead. +func (*InstallationSummary) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_installations_proto_rawDescGZIP(), []int{7} +} + +func (x *InstallationSummary) GetInstallation() *Installation { + if x != nil { + return x.Installation + } + return nil +} + +func (x *InstallationSummary) GetHealth() InstallationHealth { + if x != nil { + return x.Health + } + return InstallationHealth_INSTALLATION_HEALTH_UNSPECIFIED +} + +type ListInstallationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Installations []*InstallationSummary `protobuf:"bytes,1,rep,name=installations,proto3" json:"installations,omitempty"` + // Set when more installations remain; pass it back as page_token. Empty on the + // last page. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListInstallationsResponse) Reset() { + *x = ListInstallationsResponse{} + mi := &file_saas_accounts_v1_installations_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListInstallationsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListInstallationsResponse) ProtoMessage() {} + +func (x *ListInstallationsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_installations_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListInstallationsResponse.ProtoReflect.Descriptor instead. +func (*ListInstallationsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_installations_proto_rawDescGZIP(), []int{8} +} + +func (x *ListInstallationsResponse) GetInstallations() []*InstallationSummary { + if x != nil { + return x.Installations + } + return nil +} + +func (x *ListInstallationsResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +// AvailableSolution is one installable solution target: its immutable identity, +// the release the host currently has applied for it, and where it is routed. +type AvailableSolution struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The immutable target identity an install names. Never reused. + TargetId string `protobuf:"bytes,1,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + // The binding whose presence this target records. Carried so an operator can + // correlate a catalogue row with delivery; an installation does not join on it, + // because one binding may have several targets over time. + BindingId string `protobuf:"bytes,2,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + // The route alias the applied generation claims — `/solutions//*`. A + // later generation may move it, so it is never an identity. + RouteAlias string `protobuf:"bytes,3,opt,name=route_alias,json=routeAlias,proto3" json:"route_alias,omitempty"` + // The applied release: publisher, name, version. + ReleasePublisher string `protobuf:"bytes,4,opt,name=release_publisher,json=releasePublisher,proto3" json:"release_publisher,omitempty"` + ReleaseName string `protobuf:"bytes,5,opt,name=release_name,json=releaseName,proto3" json:"release_name,omitempty"` + ReleaseVersion string `protobuf:"bytes,6,opt,name=release_version,json=releaseVersion,proto3" json:"release_version,omitempty"` + // The generation that opened this target, and the newest applied generation. + // Equal until a later generation is applied for the same presence. + OpenedGeneration uint64 `protobuf:"varint,7,opt,name=opened_generation,json=openedGeneration,proto3" json:"opened_generation,omitempty"` + AppliedGeneration uint64 `protobuf:"varint,8,opt,name=applied_generation,json=appliedGeneration,proto3" json:"applied_generation,omitempty"` + // True when this organization already has an ACTIVE installation of this + // target. A catalogue shows it as installed rather than offering it twice; the + // unique index over (org_id, target_id) would refuse the second install anyway, + // and an administrator should not have to discover that by being refused. + Installed bool `protobuf:"varint,9,opt,name=installed,proto3" json:"installed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AvailableSolution) Reset() { + *x = AvailableSolution{} + mi := &file_saas_accounts_v1_installations_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AvailableSolution) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AvailableSolution) ProtoMessage() {} + +func (x *AvailableSolution) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_installations_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AvailableSolution.ProtoReflect.Descriptor instead. +func (*AvailableSolution) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_installations_proto_rawDescGZIP(), []int{9} +} + +func (x *AvailableSolution) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *AvailableSolution) GetBindingId() string { + if x != nil { + return x.BindingId + } + return "" +} + +func (x *AvailableSolution) GetRouteAlias() string { + if x != nil { + return x.RouteAlias + } + return "" +} + +func (x *AvailableSolution) GetReleasePublisher() string { + if x != nil { + return x.ReleasePublisher + } + return "" +} + +func (x *AvailableSolution) GetReleaseName() string { + if x != nil { + return x.ReleaseName + } + return "" +} + +func (x *AvailableSolution) GetReleaseVersion() string { + if x != nil { + return x.ReleaseVersion + } + return "" +} + +func (x *AvailableSolution) GetOpenedGeneration() uint64 { + if x != nil { + return x.OpenedGeneration + } + return 0 +} + +func (x *AvailableSolution) GetAppliedGeneration() uint64 { + if x != nil { + return x.AppliedGeneration + } + return 0 +} + +func (x *AvailableSolution) GetInstalled() bool { + if x != nil { + return x.Installed + } + return false +} + +type ListAvailableSolutionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + // Page bound. 0 -> server default; capped for the same reason the installation + // listing is capped. + PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAvailableSolutionsRequest) Reset() { + *x = ListAvailableSolutionsRequest{} + mi := &file_saas_accounts_v1_installations_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAvailableSolutionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAvailableSolutionsRequest) ProtoMessage() {} + +func (x *ListAvailableSolutionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_installations_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAvailableSolutionsRequest.ProtoReflect.Descriptor instead. +func (*ListAvailableSolutionsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_installations_proto_rawDescGZIP(), []int{10} +} + +func (x *ListAvailableSolutionsRequest) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *ListAvailableSolutionsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListAvailableSolutionsRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +type ListAvailableSolutionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Solutions []*AvailableSolution `protobuf:"bytes,1,rep,name=solutions,proto3" json:"solutions,omitempty"` + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAvailableSolutionsResponse) Reset() { + *x = ListAvailableSolutionsResponse{} + mi := &file_saas_accounts_v1_installations_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAvailableSolutionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAvailableSolutionsResponse) ProtoMessage() {} + +func (x *ListAvailableSolutionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_installations_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAvailableSolutionsResponse.ProtoReflect.Descriptor instead. +func (*ListAvailableSolutionsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_installations_proto_rawDescGZIP(), []int{11} +} + +func (x *ListAvailableSolutionsResponse) GetSolutions() []*AvailableSolution { + if x != nil { + return x.Solutions + } + return nil +} + +func (x *ListAvailableSolutionsResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + var File_saas_accounts_v1_installations_proto protoreflect.FileDescriptor const file_saas_accounts_v1_installations_proto_rawDesc = "" + "\n" + - "$saas/accounts/v1/installations.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1csaas/policy/v1/options.proto\"\x9e\x04\n" + + "$saas/accounts/v1/installations.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1csaas/policy/v1/options.proto\"\xe3\x04\n" + "\fInstallation\x12\x18\n" + "\x02id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x02id\x12\x1f\n" + "\x06org_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x126\n" + - "\x12agent_principal_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x10agentPrincipalId\x12/\n" + - "\x13solution_identifier\x18\x04 \x01(\tR\x12solutionIdentifier\x126\n" + + "\x12agent_principal_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x10agentPrincipalId\x126\n" + "\x12owner_principal_id\x18\x05 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x10ownerPrincipalId\x123\n" + "\x16co_owner_principal_ids\x18\x06 \x03(\tR\x13coOwnerPrincipalIds\x125\n" + "\x12root_scope_node_id\x18\a \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0frootScopeNodeId\x12<\n" + @@ -637,14 +1094,14 @@ const file_saas_accounts_v1_installations_proto_rawDesc = "" + "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12>\n" + "\n" + "revoked_at\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampH\x00R\trevokedAt\x88\x01\x01B\r\n" + - "\v_revoked_at\"\xbf\x04\n" + + " \x01(\v2\x1a.google.protobuf.TimestampH\x00R\trevokedAt\x88\x01\x01\x12(\n" + + "\ttarget_id\x18\v \x01(\tB\v\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01R\btargetId\x12/\n" + + "\x0erevoked_reason\x18\f \x01(\tB\b\xbaH\x05r\x03\x18\x80\x04R\rrevokedReasonB\r\n" + + "\v_revoked_atJ\x04\b\x04\x10\x05R\x13solution_identifier\"\xc4\x04\n" + "\x16InstallSolutionRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x125\n" + "\x10agent_identifier\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x0fagentIdentifier\x12;\n" + - "\x13solution_identifier\x18\x03 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x12solutionIdentifier\x12+\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x0fagentIdentifier\x12+\n" + "\fdisplay_name\x18\x04 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x04R\vdisplayName\x122\n" + "\x10root_scope_label\x18\x06 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x04R\x0erootScopeLabel\x12!\n" + "\arole_id\x18\a \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06roleId\x129\n" + @@ -652,7 +1109,8 @@ const file_saas_accounts_v1_installations_proto_rawDesc = "" + "\x16co_owner_principal_ids\x18\t \x03(\tB\x0f\xbaH\f\x92\x01\t\x10 \"\x05r\x03\xb0\x01\x01R\x13coOwnerPrincipalIds\x12<\n" + "\x11allowed_audiences\x18\n" + " \x03(\tB\x0f\xbaH\f\x92\x01\t\x10@\"\x05r\x03\x18\x80\x04R\x10allowedAudiences\x126\n" + - "\x0eallowed_scopes\x18\v \x03(\tB\x0f\xbaH\f\x92\x01\t\x10@\"\x05r\x03\x18\x80\x01R\rallowedScopesJ\x04\b\x05\x10\x06R\x0froot_scope_path\"n\n" + + "\x0eallowed_scopes\x18\v \x03(\tB\x0f\xbaH\f\x92\x01\t\x10@\"\x05r\x03\x18\x80\x01R\rallowedScopes\x12%\n" + + "\ttarget_id\x18\f \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetIdJ\x04\b\x03\x10\x04J\x04\b\x05\x10\x06R\x13solution_identifierR\x0froot_scope_path\"n\n" + "\x18UninstallSolutionRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x121\n" + "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\"\xff\x01\n" + @@ -666,7 +1124,41 @@ const file_saas_accounts_v1_installations_proto_rawDesc = "" + "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\"\x9b\x01\n" + "\x17GetInstallationResponse\x12B\n" + "\finstallation\x18\x01 \x01(\v2\x1e.saas.accounts.v1.InstallationR\finstallation\x12<\n" + - "\x06health\x18\x02 \x01(\x0e2$.saas.accounts.v1.InstallationHealthR\x06health*z\n" + + "\x06health\x18\x02 \x01(\x0e2$.saas.accounts.v1.InstallationHealthR\x06health\"\xc1\x01\n" + + "\x18ListInstallationsRequest\x12\x1f\n" + + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12<\n" + + "\x06status\x18\x02 \x01(\x0e2$.saas.accounts.v1.InstallationStatusR\x06status\x12'\n" + + "\tpage_size\x18\x03 \x01(\x05B\n" + + "\xbaH\a\x1a\x05\x18\xf4\x03(\x00R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x04 \x01(\tR\tpageToken\"\x97\x01\n" + + "\x13InstallationSummary\x12B\n" + + "\finstallation\x18\x01 \x01(\v2\x1e.saas.accounts.v1.InstallationR\finstallation\x12<\n" + + "\x06health\x18\x02 \x01(\x0e2$.saas.accounts.v1.InstallationHealthR\x06health\"\x90\x01\n" + + "\x19ListInstallationsResponse\x12K\n" + + "\rinstallations\x18\x01 \x03(\v2%.saas.accounts.v1.InstallationSummaryR\rinstallations\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xed\x02\n" + + "\x11AvailableSolution\x12%\n" + + "\ttarget_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetId\x12\x1d\n" + + "\n" + + "binding_id\x18\x02 \x01(\tR\tbindingId\x12\x1f\n" + + "\vroute_alias\x18\x03 \x01(\tR\n" + + "routeAlias\x12+\n" + + "\x11release_publisher\x18\x04 \x01(\tR\x10releasePublisher\x12!\n" + + "\frelease_name\x18\x05 \x01(\tR\vreleaseName\x12'\n" + + "\x0frelease_version\x18\x06 \x01(\tR\x0ereleaseVersion\x12+\n" + + "\x11opened_generation\x18\a \x01(\x04R\x10openedGeneration\x12-\n" + + "\x12applied_generation\x18\b \x01(\x04R\x11appliedGeneration\x12\x1c\n" + + "\tinstalled\x18\t \x01(\bR\tinstalled\"\x88\x01\n" + + "\x1dListAvailableSolutionsRequest\x12\x1f\n" + + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12'\n" + + "\tpage_size\x18\x02 \x01(\x05B\n" + + "\xbaH\a\x1a\x05\x18\xf4\x03(\x00R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x03 \x01(\tR\tpageToken\"\x8b\x01\n" + + "\x1eListAvailableSolutionsResponse\x12A\n" + + "\tsolutions\x18\x01 \x03(\v2#.saas.accounts.v1.AvailableSolutionR\tsolutions\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken*z\n" + "\x12InstallationStatus\x12#\n" + "\x1fINSTALLATION_STATUS_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aINSTALLATION_STATUS_ACTIVE\x10\x01\x12\x1f\n" + @@ -677,7 +1169,7 @@ const file_saas_accounts_v1_installations_proto_rawDesc = "" + "%INSTALLATION_HEALTH_NO_ELIGIBLE_OWNER\x10\x02\x12%\n" + "!INSTALLATION_HEALTH_AGENT_REVOKED\x10\x03\x12&\n" + "\"INSTALLATION_HEALTH_AGENT_DISABLED\x10\x04\x12.\n" + - "*INSTALLATION_HEALTH_STANDING_GRANT_MISSING\x10\x052\xef\x06\n" + + "*INSTALLATION_HEALTH_STANDING_GRANT_MISSING\x10\x052\xc5\t\n" + "\x13InstallationService\x12\xba\x01\n" + "\x0fInstallSolution\x12(.saas.accounts.v1.InstallSolutionRequest\x1a\x1e.saas.accounts.v1.Installation\"]\xc2\xf3\x18=\b\x02\x10\x04*\f\n" + "\x06org_id\x10\x02\x18\x010\x01:\x1d\n" + @@ -689,7 +1181,10 @@ const file_saas_accounts_v1_installations_proto_rawDesc = "" + "\x06org_id\x10\x02\x18\x010\x01:+\n" + "'saas.installation.ownership_transferred\x10\x02@\x01H\x04P\x03X\x03`\x01\x82\xd3\xe4\x93\x02::\x01*\"5/v1/installations/{installation_id}:transferOwnership\x12\xb9\x01\n" + "\x0fGetInstallation\x12(.saas.accounts.v1.GetInstallationRequest\x1a).saas.accounts.v1.GetInstallationResponse\"Q\xc2\xf3\x18\"\b\x02\x10\x03*\f\n" + - "\x06org_id\x10\x02\x18\x010\x01:\x02\x10\x01@\x01H\x03P\x03X\x03`\x01\x82\xd3\xe4\x93\x02%\x12#/v1/installations/{installation_id}B\xba\x01\n" + + "\x06org_id\x10\x02\x18\x010\x01:\x02\x10\x01@\x01H\x03P\x03X\x03`\x01\x82\xd3\xe4\x93\x02%\x12#/v1/installations/{installation_id}\x12\x86\x01\n" + + "\x11ListInstallations\x12*.saas.accounts.v1.ListInstallationsRequest\x1a+.saas.accounts.v1.ListInstallationsResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\xca\x01\n" + + "\x16ListAvailableSolutions\x12/.saas.accounts.v1.ListAvailableSolutionsRequest\x1a0.saas.accounts.v1.ListAvailableSolutionsResponse\"M\xc2\xf3\x18\"\b\x02\x10\x04*\f\n" + + "\x06org_id\x10\x02\x18\x010\x01:\x02\x10\x01@\x01H\x03P\x03X\x03`\x01\x82\xd3\xe4\x93\x02!\x12\x1f/v1/installations:listAvailableB\xba\x01\n" + "\x14com.saas.accounts.v1B\x12InstallationsProtoP\x01Z,accounts/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" var ( @@ -705,7 +1200,7 @@ func file_saas_accounts_v1_installations_proto_rawDescGZIP() []byte { } var file_saas_accounts_v1_installations_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_saas_accounts_v1_installations_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_saas_accounts_v1_installations_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_saas_accounts_v1_installations_proto_goTypes = []any{ (InstallationStatus)(0), // 0: saas.accounts.v1.InstallationStatus (InstallationHealth)(0), // 1: saas.accounts.v1.InstallationHealth @@ -715,28 +1210,43 @@ var file_saas_accounts_v1_installations_proto_goTypes = []any{ (*TransferInstallationOwnershipRequest)(nil), // 5: saas.accounts.v1.TransferInstallationOwnershipRequest (*GetInstallationRequest)(nil), // 6: saas.accounts.v1.GetInstallationRequest (*GetInstallationResponse)(nil), // 7: saas.accounts.v1.GetInstallationResponse - (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 9: google.protobuf.Empty + (*ListInstallationsRequest)(nil), // 8: saas.accounts.v1.ListInstallationsRequest + (*InstallationSummary)(nil), // 9: saas.accounts.v1.InstallationSummary + (*ListInstallationsResponse)(nil), // 10: saas.accounts.v1.ListInstallationsResponse + (*AvailableSolution)(nil), // 11: saas.accounts.v1.AvailableSolution + (*ListAvailableSolutionsRequest)(nil), // 12: saas.accounts.v1.ListAvailableSolutionsRequest + (*ListAvailableSolutionsResponse)(nil), // 13: saas.accounts.v1.ListAvailableSolutionsResponse + (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 15: google.protobuf.Empty } var file_saas_accounts_v1_installations_proto_depIdxs = []int32{ - 0, // 0: saas.accounts.v1.Installation.status:type_name -> saas.accounts.v1.InstallationStatus - 8, // 1: saas.accounts.v1.Installation.created_at:type_name -> google.protobuf.Timestamp - 8, // 2: saas.accounts.v1.Installation.revoked_at:type_name -> google.protobuf.Timestamp - 2, // 3: saas.accounts.v1.GetInstallationResponse.installation:type_name -> saas.accounts.v1.Installation - 1, // 4: saas.accounts.v1.GetInstallationResponse.health:type_name -> saas.accounts.v1.InstallationHealth - 3, // 5: saas.accounts.v1.InstallationService.InstallSolution:input_type -> saas.accounts.v1.InstallSolutionRequest - 4, // 6: saas.accounts.v1.InstallationService.UninstallSolution:input_type -> saas.accounts.v1.UninstallSolutionRequest - 5, // 7: saas.accounts.v1.InstallationService.TransferInstallationOwnership:input_type -> saas.accounts.v1.TransferInstallationOwnershipRequest - 6, // 8: saas.accounts.v1.InstallationService.GetInstallation:input_type -> saas.accounts.v1.GetInstallationRequest - 2, // 9: saas.accounts.v1.InstallationService.InstallSolution:output_type -> saas.accounts.v1.Installation - 9, // 10: saas.accounts.v1.InstallationService.UninstallSolution:output_type -> google.protobuf.Empty - 2, // 11: saas.accounts.v1.InstallationService.TransferInstallationOwnership:output_type -> saas.accounts.v1.Installation - 7, // 12: saas.accounts.v1.InstallationService.GetInstallation:output_type -> saas.accounts.v1.GetInstallationResponse - 9, // [9:13] is the sub-list for method output_type - 5, // [5:9] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 0, // 0: saas.accounts.v1.Installation.status:type_name -> saas.accounts.v1.InstallationStatus + 14, // 1: saas.accounts.v1.Installation.created_at:type_name -> google.protobuf.Timestamp + 14, // 2: saas.accounts.v1.Installation.revoked_at:type_name -> google.protobuf.Timestamp + 2, // 3: saas.accounts.v1.GetInstallationResponse.installation:type_name -> saas.accounts.v1.Installation + 1, // 4: saas.accounts.v1.GetInstallationResponse.health:type_name -> saas.accounts.v1.InstallationHealth + 0, // 5: saas.accounts.v1.ListInstallationsRequest.status:type_name -> saas.accounts.v1.InstallationStatus + 2, // 6: saas.accounts.v1.InstallationSummary.installation:type_name -> saas.accounts.v1.Installation + 1, // 7: saas.accounts.v1.InstallationSummary.health:type_name -> saas.accounts.v1.InstallationHealth + 9, // 8: saas.accounts.v1.ListInstallationsResponse.installations:type_name -> saas.accounts.v1.InstallationSummary + 11, // 9: saas.accounts.v1.ListAvailableSolutionsResponse.solutions:type_name -> saas.accounts.v1.AvailableSolution + 3, // 10: saas.accounts.v1.InstallationService.InstallSolution:input_type -> saas.accounts.v1.InstallSolutionRequest + 4, // 11: saas.accounts.v1.InstallationService.UninstallSolution:input_type -> saas.accounts.v1.UninstallSolutionRequest + 5, // 12: saas.accounts.v1.InstallationService.TransferInstallationOwnership:input_type -> saas.accounts.v1.TransferInstallationOwnershipRequest + 6, // 13: saas.accounts.v1.InstallationService.GetInstallation:input_type -> saas.accounts.v1.GetInstallationRequest + 8, // 14: saas.accounts.v1.InstallationService.ListInstallations:input_type -> saas.accounts.v1.ListInstallationsRequest + 12, // 15: saas.accounts.v1.InstallationService.ListAvailableSolutions:input_type -> saas.accounts.v1.ListAvailableSolutionsRequest + 2, // 16: saas.accounts.v1.InstallationService.InstallSolution:output_type -> saas.accounts.v1.Installation + 15, // 17: saas.accounts.v1.InstallationService.UninstallSolution:output_type -> google.protobuf.Empty + 2, // 18: saas.accounts.v1.InstallationService.TransferInstallationOwnership:output_type -> saas.accounts.v1.Installation + 7, // 19: saas.accounts.v1.InstallationService.GetInstallation:output_type -> saas.accounts.v1.GetInstallationResponse + 10, // 20: saas.accounts.v1.InstallationService.ListInstallations:output_type -> saas.accounts.v1.ListInstallationsResponse + 13, // 21: saas.accounts.v1.InstallationService.ListAvailableSolutions:output_type -> saas.accounts.v1.ListAvailableSolutionsResponse + 16, // [16:22] is the sub-list for method output_type + 10, // [10:16] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_saas_accounts_v1_installations_proto_init() } @@ -751,7 +1261,7 @@ func file_saas_accounts_v1_installations_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_installations_proto_rawDesc), len(file_saas_accounts_v1_installations_proto_rawDesc)), NumEnums: 2, - NumMessages: 6, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.gw.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.gw.go index c987977a1..99204fb70 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.gw.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.gw.go @@ -205,6 +205,41 @@ func local_request_InstallationService_GetInstallation_0(ctx context.Context, ma return msg, metadata, err } +var filter_InstallationService_ListAvailableSolutions_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_InstallationService_ListAvailableSolutions_0(ctx context.Context, marshaler runtime.Marshaler, client InstallationServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListAvailableSolutionsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_InstallationService_ListAvailableSolutions_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListAvailableSolutions(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_InstallationService_ListAvailableSolutions_0(ctx context.Context, marshaler runtime.Marshaler, server InstallationServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListAvailableSolutionsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_InstallationService_ListAvailableSolutions_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListAvailableSolutions(ctx, &protoReq) + return msg, metadata, err +} + // RegisterInstallationServiceHandlerServer registers the http handlers for service InstallationService to "mux". // UnaryRPC :call InstallationServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -291,6 +326,26 @@ func RegisterInstallationServiceHandlerServer(ctx context.Context, mux *runtime. } forward_InstallationService_GetInstallation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_InstallationService_ListAvailableSolutions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/saas.accounts.v1.InstallationService/ListAvailableSolutions", runtime.WithHTTPPathPattern("/v1/installations:listAvailable")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_InstallationService_ListAvailableSolutions_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_InstallationService_ListAvailableSolutions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -399,6 +454,23 @@ func RegisterInstallationServiceHandlerClient(ctx context.Context, mux *runtime. } forward_InstallationService_GetInstallation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_InstallationService_ListAvailableSolutions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/saas.accounts.v1.InstallationService/ListAvailableSolutions", runtime.WithHTTPPathPattern("/v1/installations:listAvailable")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_InstallationService_ListAvailableSolutions_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_InstallationService_ListAvailableSolutions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -407,6 +479,7 @@ var ( pattern_InstallationService_UninstallSolution_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "installations", "installation_id"}, "uninstall")) pattern_InstallationService_TransferInstallationOwnership_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "installations", "installation_id"}, "transferOwnership")) pattern_InstallationService_GetInstallation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "installations", "installation_id"}, "")) + pattern_InstallationService_ListAvailableSolutions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "installations"}, "listAvailable")) ) var ( @@ -414,4 +487,5 @@ var ( forward_InstallationService_UninstallSolution_0 = runtime.ForwardResponseMessage forward_InstallationService_TransferInstallationOwnership_0 = runtime.ForwardResponseMessage forward_InstallationService_GetInstallation_0 = runtime.ForwardResponseMessage + forward_InstallationService_ListAvailableSolutions_0 = runtime.ForwardResponseMessage ) diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go index 6857a7f4f..91a418e44 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations_grpc.pb.go @@ -25,6 +25,8 @@ const ( InstallationService_UninstallSolution_FullMethodName = "/saas.accounts.v1.InstallationService/UninstallSolution" InstallationService_TransferInstallationOwnership_FullMethodName = "/saas.accounts.v1.InstallationService/TransferInstallationOwnership" InstallationService_GetInstallation_FullMethodName = "/saas.accounts.v1.InstallationService/GetInstallation" + InstallationService_ListInstallations_FullMethodName = "/saas.accounts.v1.InstallationService/ListInstallations" + InstallationService_ListAvailableSolutions_FullMethodName = "/saas.accounts.v1.InstallationService/ListAvailableSolutions" ) // InstallationServiceClient is the client API for InstallationService service. @@ -54,6 +56,30 @@ type InstallationServiceClient interface { // can see when an installation has gone unhealthy (owner offboarded, agent // disabled, grant expired) before the next headless task fails closed. GetInstallation(ctx context.Context, in *GetInstallationRequest, opts ...grpc.CallOption) (*GetInstallationResponse, error) + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + ListInstallations(ctx context.Context, in *ListInstallationsRequest, opts ...grpc.CallOption) (*ListInstallationsResponse, error) + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + ListAvailableSolutions(ctx context.Context, in *ListAvailableSolutionsRequest, opts ...grpc.CallOption) (*ListAvailableSolutionsResponse, error) } type installationServiceClient struct { @@ -104,6 +130,26 @@ func (c *installationServiceClient) GetInstallation(ctx context.Context, in *Get return out, nil } +func (c *installationServiceClient) ListInstallations(ctx context.Context, in *ListInstallationsRequest, opts ...grpc.CallOption) (*ListInstallationsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListInstallationsResponse) + err := c.cc.Invoke(ctx, InstallationService_ListInstallations_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *installationServiceClient) ListAvailableSolutions(ctx context.Context, in *ListAvailableSolutionsRequest, opts ...grpc.CallOption) (*ListAvailableSolutionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListAvailableSolutionsResponse) + err := c.cc.Invoke(ctx, InstallationService_ListAvailableSolutions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // InstallationServiceServer is the server API for InstallationService service. // All implementations must embed UnimplementedInstallationServiceServer // for forward compatibility. @@ -131,6 +177,30 @@ type InstallationServiceServer interface { // can see when an installation has gone unhealthy (owner offboarded, agent // disabled, grant expired) before the next headless task fails closed. GetInstallation(context.Context, *GetInstallationRequest) (*GetInstallationResponse, error) + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + ListInstallations(context.Context, *ListInstallationsRequest) (*ListInstallationsResponse, error) + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + ListAvailableSolutions(context.Context, *ListAvailableSolutionsRequest) (*ListAvailableSolutionsResponse, error) mustEmbedUnimplementedInstallationServiceServer() } @@ -153,6 +223,12 @@ func (UnimplementedInstallationServiceServer) TransferInstallationOwnership(cont func (UnimplementedInstallationServiceServer) GetInstallation(context.Context, *GetInstallationRequest) (*GetInstallationResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetInstallation not implemented") } +func (UnimplementedInstallationServiceServer) ListInstallations(context.Context, *ListInstallationsRequest) (*ListInstallationsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListInstallations not implemented") +} +func (UnimplementedInstallationServiceServer) ListAvailableSolutions(context.Context, *ListAvailableSolutionsRequest) (*ListAvailableSolutionsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListAvailableSolutions not implemented") +} func (UnimplementedInstallationServiceServer) mustEmbedUnimplementedInstallationServiceServer() {} func (UnimplementedInstallationServiceServer) testEmbeddedByValue() {} @@ -246,6 +322,42 @@ func _InstallationService_GetInstallation_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _InstallationService_ListInstallations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListInstallationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InstallationServiceServer).ListInstallations(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InstallationService_ListInstallations_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InstallationServiceServer).ListInstallations(ctx, req.(*ListInstallationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _InstallationService_ListAvailableSolutions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAvailableSolutionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InstallationServiceServer).ListAvailableSolutions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InstallationService_ListAvailableSolutions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InstallationServiceServer).ListAvailableSolutions(ctx, req.(*ListAvailableSolutionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // InstallationService_ServiceDesc is the grpc.ServiceDesc for InstallationService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -269,6 +381,14 @@ var InstallationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetInstallation", Handler: _InstallationService_GetInstallation_Handler, }, + { + MethodName: "ListInstallations", + Handler: _InstallationService_ListInstallations_Handler, + }, + { + MethodName: "ListAvailableSolutions", + Handler: _InstallationService_ListAvailableSolutions_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "saas/accounts/v1/installations.proto", diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go index 63dd5086e..916ba1f5d 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go @@ -4575,12 +4575,17 @@ func (x *ModuleCurrentInstallationRequest) GetInstallationId() string { } type ModuleCurrentInstallationResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` - TenantId string `protobuf:"bytes,2,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` - SolutionIdentifier string `protobuf:"bytes,3,opt,name=solution_identifier,json=solutionIdentifier,proto3" json:"solution_identifier,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + TenantId string `protobuf:"bytes,2,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + // The immutable target this installation was consented to: one continuous + // period of one binding's presence, never reused. + TargetId string `protobuf:"bytes,4,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + // The delivered presence binding whose period that target records. This is + // what an authority decision keys on; the alias is display only. + BindingId string `protobuf:"bytes,5,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ModuleCurrentInstallationResponse) Reset() { @@ -4627,9 +4632,16 @@ func (x *ModuleCurrentInstallationResponse) GetTenantId() string { return "" } -func (x *ModuleCurrentInstallationResponse) GetSolutionIdentifier() string { +func (x *ModuleCurrentInstallationResponse) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *ModuleCurrentInstallationResponse) GetBindingId() string { if x != nil { - return x.SolutionIdentifier + return x.BindingId } return "" } @@ -4974,11 +4986,13 @@ const file_saas_accounts_v1_module_capabilities_proto_rawDesc = "" + "\x11expected_revision\x18\x04 \x01(\x03R\x10expectedRevision\"\x9d\x01\n" + " ModuleCurrentInstallationRequest\x12F\n" + "\x19parent_work_context_token\x18\x01 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken\x121\n" + - "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\"\x9a\x01\n" + + "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\"\xca\x01\n" + "!ModuleCurrentInstallationResponse\x12'\n" + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\x12\x1b\n" + - "\ttenant_id\x18\x02 \x01(\tR\btenantId\x12/\n" + - "\x13solution_identifier\x18\x03 \x01(\tR\x12solutionIdentifier*\xb8\x02\n" + + "\ttenant_id\x18\x02 \x01(\tR\btenantId\x12%\n" + + "\ttarget_id\x18\x04 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetId\x12\x1d\n" + + "\n" + + "binding_id\x18\x05 \x01(\tR\tbindingIdJ\x04\b\x03\x10\x04R\x13solution_identifier*\xb8\x02\n" + "\x14ModuleAuditFieldKind\x12'\n" + "#MODULE_AUDIT_FIELD_KIND_UNSPECIFIED\x10\x00\x12\"\n" + "\x1eMODULE_AUDIT_FIELD_KIND_STRING\x10\x01\x12 \n" + diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go new file mode 100644 index 000000000..2fa718594 --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go @@ -0,0 +1,322 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/accounts/v1/solution_entitlements.proto + +package accountsv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +type SolutionEntitlement struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstallationId string `protobuf:"bytes,2,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + RootScopeNodeId string `protobuf:"bytes,3,opt,name=root_scope_node_id,json=rootScopeNodeId,proto3" json:"root_scope_node_id,omitempty"` + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + Healthy bool `protobuf:"varint,4,opt,name=healthy,proto3" json:"healthy,omitempty"` + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + TargetId string `protobuf:"bytes,5,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SolutionEntitlement) Reset() { + *x = SolutionEntitlement{} + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SolutionEntitlement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolutionEntitlement) ProtoMessage() {} + +func (x *SolutionEntitlement) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SolutionEntitlement.ProtoReflect.Descriptor instead. +func (*SolutionEntitlement) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP(), []int{0} +} + +func (x *SolutionEntitlement) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +func (x *SolutionEntitlement) GetRootScopeNodeId() string { + if x != nil { + return x.RootScopeNodeId + } + return "" +} + +func (x *SolutionEntitlement) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *SolutionEntitlement) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +type ListSolutionEntitlementsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + SubjectId string `protobuf:"bytes,2,opt,name=subject_id,json=subjectId,proto3" json:"subject_id,omitempty"` + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque cursor from a previous response's next_page_token; empty for page one. + PageToken string `protobuf:"bytes,4,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionEntitlementsRequest) Reset() { + *x = ListSolutionEntitlementsRequest{} + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSolutionEntitlementsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSolutionEntitlementsRequest) ProtoMessage() {} + +func (x *ListSolutionEntitlementsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSolutionEntitlementsRequest.ProtoReflect.Descriptor instead. +func (*ListSolutionEntitlementsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP(), []int{1} +} + +func (x *ListSolutionEntitlementsRequest) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *ListSolutionEntitlementsRequest) GetSubjectId() string { + if x != nil { + return x.SubjectId + } + return "" +} + +func (x *ListSolutionEntitlementsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListSolutionEntitlementsRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +type ListSolutionEntitlementsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entitlements []*SolutionEntitlement `protobuf:"bytes,1,rep,name=entitlements,proto3" json:"entitlements,omitempty"` + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionEntitlementsResponse) Reset() { + *x = ListSolutionEntitlementsResponse{} + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSolutionEntitlementsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSolutionEntitlementsResponse) ProtoMessage() {} + +func (x *ListSolutionEntitlementsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSolutionEntitlementsResponse.ProtoReflect.Descriptor instead. +func (*ListSolutionEntitlementsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP(), []int{2} +} + +func (x *ListSolutionEntitlementsResponse) GetEntitlements() []*SolutionEntitlement { + if x != nil { + return x.Entitlements + } + return nil +} + +func (x *ListSolutionEntitlementsResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +var File_saas_accounts_v1_solution_entitlements_proto protoreflect.FileDescriptor + +const file_saas_accounts_v1_solution_entitlements_proto_rawDesc = "" + + "\n" + + ",saas/accounts/v1/solution_entitlements.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\"\xdb\x01\n" + + "\x13SolutionEntitlement\x121\n" + + "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\x125\n" + + "\x12root_scope_node_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0frootScopeNodeId\x12\x18\n" + + "\ahealthy\x18\x04 \x01(\bR\ahealthy\x12%\n" + + "\ttarget_id\x18\x05 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetIdJ\x04\b\x01\x10\x02R\x13solution_identifier\"\xb3\x01\n" + + "\x1fListSolutionEntitlementsRequest\x12\x1f\n" + + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12'\n" + + "\n" + + "subject_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsubjectId\x12'\n" + + "\tpage_size\x18\x03 \x01(\x05B\n" + + "\xbaH\a\x1a\x05\x18\xf4\x03(\x00R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x04 \x01(\tR\tpageToken\"\x95\x01\n" + + " ListSolutionEntitlementsResponse\x12I\n" + + "\fentitlements\x18\x01 \x03(\v2%.saas.accounts.v1.SolutionEntitlementR\fentitlements\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageTokenB\xc1\x01\n" + + "\x14com.saas.accounts.v1B\x19SolutionEntitlementsProtoP\x01Z,accounts/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" + +var ( + file_saas_accounts_v1_solution_entitlements_proto_rawDescOnce sync.Once + file_saas_accounts_v1_solution_entitlements_proto_rawDescData []byte +) + +func file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP() []byte { + file_saas_accounts_v1_solution_entitlements_proto_rawDescOnce.Do(func() { + file_saas_accounts_v1_solution_entitlements_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_entitlements_proto_rawDesc), len(file_saas_accounts_v1_solution_entitlements_proto_rawDesc))) + }) + return file_saas_accounts_v1_solution_entitlements_proto_rawDescData +} + +var file_saas_accounts_v1_solution_entitlements_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_saas_accounts_v1_solution_entitlements_proto_goTypes = []any{ + (*SolutionEntitlement)(nil), // 0: saas.accounts.v1.SolutionEntitlement + (*ListSolutionEntitlementsRequest)(nil), // 1: saas.accounts.v1.ListSolutionEntitlementsRequest + (*ListSolutionEntitlementsResponse)(nil), // 2: saas.accounts.v1.ListSolutionEntitlementsResponse +} +var file_saas_accounts_v1_solution_entitlements_proto_depIdxs = []int32{ + 0, // 0: saas.accounts.v1.ListSolutionEntitlementsResponse.entitlements:type_name -> saas.accounts.v1.SolutionEntitlement + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_saas_accounts_v1_solution_entitlements_proto_init() } +func file_saas_accounts_v1_solution_entitlements_proto_init() { + if File_saas_accounts_v1_solution_entitlements_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_entitlements_proto_rawDesc), len(file_saas_accounts_v1_solution_entitlements_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_saas_accounts_v1_solution_entitlements_proto_goTypes, + DependencyIndexes: file_saas_accounts_v1_solution_entitlements_proto_depIdxs, + MessageInfos: file_saas_accounts_v1_solution_entitlements_proto_msgTypes, + }.Build() + File_saas_accounts_v1_solution_entitlements_proto = out.File + file_saas_accounts_v1_solution_entitlements_proto_goTypes = nil + file_saas_accounts_v1_solution_entitlements_proto_depIdxs = nil +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service.pb.go new file mode 100644 index 000000000..bd56b06f1 --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service.pb.go @@ -0,0 +1,70 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/accounts/v1/solution_entitlements_service.proto + +package accountsv1 + +import ( + _ "accounts/pkg/gen/saas/policy/v1" + reflect "reflect" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_saas_accounts_v1_solution_entitlements_service_proto protoreflect.FileDescriptor + +const file_saas_accounts_v1_solution_entitlements_service_proto_rawDesc = "" + + "\n" + + "4saas/accounts/v1/solution_entitlements_service.proto\x12\x10saas.accounts.v1\x1a,saas/accounts/v1/solution_entitlements.proto\x1a\x1csaas/policy/v1/options.proto2\xba\x01\n" + + "\x1aSolutionEntitlementService\x12\x9b\x01\n" + + "\x18ListSolutionEntitlements\x121.saas.accounts.v1.ListSolutionEntitlementsRequest\x1a2.saas.accounts.v1.ListSolutionEntitlementsResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01B\xc8\x01\n" + + "\x14com.saas.accounts.v1B SolutionEntitlementsServiceProtoP\x01Z,accounts/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" + +var file_saas_accounts_v1_solution_entitlements_service_proto_goTypes = []any{ + (*ListSolutionEntitlementsRequest)(nil), // 0: saas.accounts.v1.ListSolutionEntitlementsRequest + (*ListSolutionEntitlementsResponse)(nil), // 1: saas.accounts.v1.ListSolutionEntitlementsResponse +} +var file_saas_accounts_v1_solution_entitlements_service_proto_depIdxs = []int32{ + 0, // 0: saas.accounts.v1.SolutionEntitlementService.ListSolutionEntitlements:input_type -> saas.accounts.v1.ListSolutionEntitlementsRequest + 1, // 1: saas.accounts.v1.SolutionEntitlementService.ListSolutionEntitlements:output_type -> saas.accounts.v1.ListSolutionEntitlementsResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_saas_accounts_v1_solution_entitlements_service_proto_init() } +func file_saas_accounts_v1_solution_entitlements_service_proto_init() { + if File_saas_accounts_v1_solution_entitlements_service_proto != nil { + return + } + file_saas_accounts_v1_solution_entitlements_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_entitlements_service_proto_rawDesc), len(file_saas_accounts_v1_solution_entitlements_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_saas_accounts_v1_solution_entitlements_service_proto_goTypes, + DependencyIndexes: file_saas_accounts_v1_solution_entitlements_service_proto_depIdxs, + }.Build() + File_saas_accounts_v1_solution_entitlements_service_proto = out.File + file_saas_accounts_v1_solution_entitlements_service_proto_goTypes = nil + file_saas_accounts_v1_solution_entitlements_service_proto_depIdxs = nil +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service_grpc.pb.go new file mode 100644 index 000000000..11a14c095 --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service_grpc.pb.go @@ -0,0 +1,143 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: saas/accounts/v1/solution_entitlements_service.proto + +package accountsv1 + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SolutionEntitlementService_ListSolutionEntitlements_FullMethodName = "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" +) + +// SolutionEntitlementServiceClient is the client API for SolutionEntitlementService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// SolutionEntitlementService answers what one viewer may use inside one +// organization. Internal-tier: it takes both the organization and the subject as +// request fields, so it is an authority oracle about an arbitrary principal and +// only a caller holding the cluster-internal credential may ask. The caller that +// does is the auth-gateway, naming the tenant and the viewer it projected from a +// verified identity — never values a browser supplied. +type SolutionEntitlementServiceClient interface { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + ListSolutionEntitlements(ctx context.Context, in *ListSolutionEntitlementsRequest, opts ...grpc.CallOption) (*ListSolutionEntitlementsResponse, error) +} + +type solutionEntitlementServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSolutionEntitlementServiceClient(cc grpc.ClientConnInterface) SolutionEntitlementServiceClient { + return &solutionEntitlementServiceClient{cc} +} + +func (c *solutionEntitlementServiceClient) ListSolutionEntitlements(ctx context.Context, in *ListSolutionEntitlementsRequest, opts ...grpc.CallOption) (*ListSolutionEntitlementsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSolutionEntitlementsResponse) + err := c.cc.Invoke(ctx, SolutionEntitlementService_ListSolutionEntitlements_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SolutionEntitlementServiceServer is the server API for SolutionEntitlementService service. +// All implementations must embed UnimplementedSolutionEntitlementServiceServer +// for forward compatibility. +// +// SolutionEntitlementService answers what one viewer may use inside one +// organization. Internal-tier: it takes both the organization and the subject as +// request fields, so it is an authority oracle about an arbitrary principal and +// only a caller holding the cluster-internal credential may ask. The caller that +// does is the auth-gateway, naming the tenant and the viewer it projected from a +// verified identity — never values a browser supplied. +type SolutionEntitlementServiceServer interface { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + ListSolutionEntitlements(context.Context, *ListSolutionEntitlementsRequest) (*ListSolutionEntitlementsResponse, error) + mustEmbedUnimplementedSolutionEntitlementServiceServer() +} + +// UnimplementedSolutionEntitlementServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSolutionEntitlementServiceServer struct{} + +func (UnimplementedSolutionEntitlementServiceServer) ListSolutionEntitlements(context.Context, *ListSolutionEntitlementsRequest) (*ListSolutionEntitlementsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSolutionEntitlements not implemented") +} +func (UnimplementedSolutionEntitlementServiceServer) mustEmbedUnimplementedSolutionEntitlementServiceServer() { +} +func (UnimplementedSolutionEntitlementServiceServer) testEmbeddedByValue() {} + +// UnsafeSolutionEntitlementServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SolutionEntitlementServiceServer will +// result in compilation errors. +type UnsafeSolutionEntitlementServiceServer interface { + mustEmbedUnimplementedSolutionEntitlementServiceServer() +} + +func RegisterSolutionEntitlementServiceServer(s grpc.ServiceRegistrar, srv SolutionEntitlementServiceServer) { + // If the following call panics, it indicates UnimplementedSolutionEntitlementServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SolutionEntitlementService_ServiceDesc, srv) +} + +func _SolutionEntitlementService_ListSolutionEntitlements_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSolutionEntitlementsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SolutionEntitlementServiceServer).ListSolutionEntitlements(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SolutionEntitlementService_ListSolutionEntitlements_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SolutionEntitlementServiceServer).ListSolutionEntitlements(ctx, req.(*ListSolutionEntitlementsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SolutionEntitlementService_ServiceDesc is the grpc.ServiceDesc for SolutionEntitlementService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SolutionEntitlementService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "saas.accounts.v1.SolutionEntitlementService", + HandlerType: (*SolutionEntitlementServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListSolutionEntitlements", + Handler: _SolutionEntitlementService_ListSolutionEntitlements_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "saas/accounts/v1/solution_entitlements_service.proto", +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go index d05bc744c..1f0bf92f6 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go @@ -11,7 +11,6 @@ import ( sync "sync" unsafe "unsafe" - _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" @@ -24,28 +23,22 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. type SolutionRegistrationStatus int32 const ( SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_UNSPECIFIED SolutionRegistrationStatus = 0 - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE SolutionRegistrationStatus = 1 - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_PENDING SolutionRegistrationStatus = 2 - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_EXPIRED SolutionRegistrationStatus = 3 // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE SolutionRegistrationStatus = 4 - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_TOMBSTONED SolutionRegistrationStatus = 5 ) @@ -55,7 +48,6 @@ var ( 0: "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED", 1: "SOLUTION_REGISTRATION_STATUS_ACTIVE", 2: "SOLUTION_REGISTRATION_STATUS_PENDING", - 3: "SOLUTION_REGISTRATION_STATUS_EXPIRED", 4: "SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE", 5: "SOLUTION_REGISTRATION_STATUS_TOMBSTONED", } @@ -63,7 +55,6 @@ var ( "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED": 0, "SOLUTION_REGISTRATION_STATUS_ACTIVE": 1, "SOLUTION_REGISTRATION_STATUS_PENDING": 2, - "SOLUTION_REGISTRATION_STATUS_EXPIRED": 3, "SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE": 4, "SOLUTION_REGISTRATION_STATUS_TOMBSTONED": 5, } @@ -96,16 +87,80 @@ func (SolutionRegistrationStatus) EnumDescriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{0} } +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +type SolutionDeclaredKind int32 + +const ( + SolutionDeclaredKind_SOLUTION_DECLARED_KIND_UNSPECIFIED SolutionDeclaredKind = 0 + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION SolutionDeclaredKind = 1 + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE SolutionDeclaredKind = 2 +) + +// Enum value maps for SolutionDeclaredKind. +var ( + SolutionDeclaredKind_name = map[int32]string{ + 0: "SOLUTION_DECLARED_KIND_UNSPECIFIED", + 1: "SOLUTION_DECLARED_KIND_SOLUTION", + 2: "SOLUTION_DECLARED_KIND_MODULE", + } + SolutionDeclaredKind_value = map[string]int32{ + "SOLUTION_DECLARED_KIND_UNSPECIFIED": 0, + "SOLUTION_DECLARED_KIND_SOLUTION": 1, + "SOLUTION_DECLARED_KIND_MODULE": 2, + } +) + +func (x SolutionDeclaredKind) Enum() *SolutionDeclaredKind { + p := new(SolutionDeclaredKind) + *p = x + return p +} + +func (x SolutionDeclaredKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SolutionDeclaredKind) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_solution_registry_proto_enumTypes[1].Descriptor() +} + +func (SolutionDeclaredKind) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_solution_registry_proto_enumTypes[1] +} + +func (x SolutionDeclaredKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SolutionDeclaredKind.Descriptor instead. +func (SolutionDeclaredKind) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{1} +} + // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. type SolutionFrontendBinding struct { state protoimpl.MessageState `protogen:"open.v1"` Revision int64 `protobuf:"varint,1,opt,name=revision,proto3" json:"revision,omitempty"` Manifest string `protobuf:"bytes,2,opt,name=manifest,proto3" json:"manifest,omitempty"` ContractVersion string `protobuf:"bytes,3,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - LeaseExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=lease_expires_at,json=leaseExpiresAt,proto3" json:"lease_expires_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -161,13 +216,6 @@ func (x *SolutionFrontendBinding) GetContractVersion() string { return "" } -func (x *SolutionFrontendBinding) GetLeaseExpiresAt() *timestamppb.Timestamp { - if x != nil { - return x.LeaseExpiresAt - } - return nil -} - // SolutionBackendBinding is the stored backend half: the upstream the gateway // proxies to and the logical service alias it is addressed by. type SolutionBackendBinding struct { @@ -176,7 +224,6 @@ type SolutionBackendBinding struct { Upstream string `protobuf:"bytes,2,opt,name=upstream,proto3" json:"upstream,omitempty"` ServiceAlias string `protobuf:"bytes,3,opt,name=service_alias,json=serviceAlias,proto3" json:"service_alias,omitempty"` ContractVersion string `protobuf:"bytes,4,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - LeaseExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=lease_expires_at,json=leaseExpiresAt,proto3" json:"lease_expires_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -239,21 +286,13 @@ func (x *SolutionBackendBinding) GetContractVersion() string { return "" } -func (x *SolutionBackendBinding) GetLeaseExpiresAt() *timestamppb.Timestamp { - if x != nil { - return x.LeaseExpiresAt - } - return nil -} - // SolutionRegistration is the canonical record. revision is drawn from one // registry-wide sequence, so revisions are comparable across records and a // consumer can order two snapshots without a wall clock. type SolutionRegistration struct { state protoimpl.MessageState `protogen:"open.v1"` SolutionId string `protobuf:"bytes,1,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. Publisher string `protobuf:"bytes,2,opt,name=publisher,proto3" json:"publisher,omitempty"` Revision int64 `protobuf:"varint,3,opt,name=revision,proto3" json:"revision,omitempty"` Status SolutionRegistrationStatus `protobuf:"varint,4,opt,name=status,proto3,enum=saas.accounts.v1.SolutionRegistrationStatus" json:"status,omitempty"` @@ -261,20 +300,10 @@ type SolutionRegistration struct { Backend *SolutionBackendBinding `protobuf:"bytes,6,opt,name=backend,proto3,oneof" json:"backend,omitempty"` UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` TombstonedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=tombstoned_at,json=tombstonedAt,proto3,oneof" json:"tombstoned_at,omitempty"` - // The seed a solution's runtime boundary is derived from (issue #1015). - // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - RuntimeBoundary string `protobuf:"bytes,9,opt,name=runtime_boundary,json=runtimeBoundary,proto3" json:"runtime_boundary,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The binding and immutable target that declared this record. + Declared *SolutionDeclaredBinding `protobuf:"bytes,10,opt,name=declared,proto3,oneof" json:"declared,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SolutionRegistration) Reset() { @@ -363,37 +392,39 @@ func (x *SolutionRegistration) GetTombstonedAt() *timestamppb.Timestamp { return nil } -func (x *SolutionRegistration) GetRuntimeBoundary() string { +func (x *SolutionRegistration) GetDeclared() *SolutionDeclaredBinding { if x != nil { - return x.RuntimeBoundary + return x.Declared } - return "" + return nil } -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -type SolutionFrontendRegistration struct { - state protoimpl.MessageState `protogen:"open.v1"` - Manifest string `protobuf:"bytes,1,opt,name=manifest,proto3" json:"manifest,omitempty"` - ContractVersion string `protobuf:"bytes,2,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// ListSolutionRegistrationsRequest reads the whole registry. There is one +// registry per deployment and it holds tens of records at most, so consumers +// rebuild their cache from a full snapshot rather than tailing a change feed. +type ListSolutionRegistrationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tombstones are omitted by default: a routing cache wants the live set. An + // operator diagnosing "was this deleted or never registered?" asks for them. + IncludeTombstoned bool `protobuf:"varint,1,opt,name=include_tombstoned,json=includeTombstoned,proto3" json:"include_tombstoned,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SolutionFrontendRegistration) Reset() { - *x = SolutionFrontendRegistration{} +func (x *ListSolutionRegistrationsRequest) Reset() { + *x = ListSolutionRegistrationsRequest{} mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SolutionFrontendRegistration) String() string { +func (x *ListSolutionRegistrationsRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SolutionFrontendRegistration) ProtoMessage() {} +func (*ListSolutionRegistrationsRequest) ProtoMessage() {} -func (x *SolutionFrontendRegistration) ProtoReflect() protoreflect.Message { +func (x *ListSolutionRegistrationsRequest) ProtoReflect() protoreflect.Message { mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -405,51 +436,42 @@ func (x *SolutionFrontendRegistration) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SolutionFrontendRegistration.ProtoReflect.Descriptor instead. -func (*SolutionFrontendRegistration) Descriptor() ([]byte, []int) { +// Deprecated: Use ListSolutionRegistrationsRequest.ProtoReflect.Descriptor instead. +func (*ListSolutionRegistrationsRequest) Descriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{3} } -func (x *SolutionFrontendRegistration) GetManifest() string { - if x != nil { - return x.Manifest - } - return "" -} - -func (x *SolutionFrontendRegistration) GetContractVersion() string { +func (x *ListSolutionRegistrationsRequest) GetIncludeTombstoned() bool { if x != nil { - return x.ContractVersion + return x.IncludeTombstoned } - return "" + return false } -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -type SolutionBackendRegistration struct { - state protoimpl.MessageState `protogen:"open.v1"` - Upstream string `protobuf:"bytes,1,opt,name=upstream,proto3" json:"upstream,omitempty"` - ServiceAlias string `protobuf:"bytes,2,opt,name=service_alias,json=serviceAlias,proto3" json:"service_alias,omitempty"` - ContractVersion string `protobuf:"bytes,3,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type ListSolutionRegistrationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Registrations []*SolutionRegistration `protobuf:"bytes,1,rep,name=registrations,proto3" json:"registrations,omitempty"` + // Highest revision in the registry, tombstones included. Two consumers that + // report the same registry_revision have converged on the same state. + RegistryRevision int64 `protobuf:"varint,2,opt,name=registry_revision,json=registryRevision,proto3" json:"registry_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SolutionBackendRegistration) Reset() { - *x = SolutionBackendRegistration{} +func (x *ListSolutionRegistrationsResponse) Reset() { + *x = ListSolutionRegistrationsResponse{} mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SolutionBackendRegistration) String() string { +func (x *ListSolutionRegistrationsResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SolutionBackendRegistration) ProtoMessage() {} +func (*ListSolutionRegistrationsResponse) ProtoMessage() {} -func (x *SolutionBackendRegistration) ProtoReflect() protoreflect.Message { +func (x *ListSolutionRegistrationsResponse) ProtoReflect() protoreflect.Message { mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -461,75 +483,71 @@ func (x *SolutionBackendRegistration) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SolutionBackendRegistration.ProtoReflect.Descriptor instead. -func (*SolutionBackendRegistration) Descriptor() ([]byte, []int) { +// Deprecated: Use ListSolutionRegistrationsResponse.ProtoReflect.Descriptor instead. +func (*ListSolutionRegistrationsResponse) Descriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{4} } -func (x *SolutionBackendRegistration) GetUpstream() string { - if x != nil { - return x.Upstream - } - return "" -} - -func (x *SolutionBackendRegistration) GetServiceAlias() string { +func (x *ListSolutionRegistrationsResponse) GetRegistrations() []*SolutionRegistration { if x != nil { - return x.ServiceAlias + return x.Registrations } - return "" + return nil } -func (x *SolutionBackendRegistration) GetContractVersion() string { +func (x *ListSolutionRegistrationsResponse) GetRegistryRevision() int64 { if x != nil { - return x.ContractVersion + return x.RegistryRevision } - return "" + return 0 } -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -type PutSolutionRegistrationRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - SolutionId string `protobuf:"bytes,1,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` - Publisher string `protobuf:"bytes,2,opt,name=publisher,proto3" json:"publisher,omitempty"` - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - ExpectedRevision *int64 `protobuf:"varint,3,opt,name=expected_revision,json=expectedRevision,proto3,oneof" json:"expected_revision,omitempty"` - // Requested liveness window for this half. The registrant renews within it. - LeaseSeconds uint32 `protobuf:"varint,4,opt,name=lease_seconds,json=leaseSeconds,proto3" json:"lease_seconds,omitempty"` - // Types that are valid to be assigned to Half: +// SolutionDeclaredBinding identifies the applied declaration and target. +type SolutionDeclaredBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + // The generation of that binding the host applied into this record. + Generation uint64 `protobuf:"varint,2,opt,name=generation,proto3" json:"generation,omitempty"` + // publisher/name@version of the applied generation. + Release string `protobuf:"bytes,3,opt,name=release,proto3" json:"release,omitempty"` + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. // - // *PutSolutionRegistrationRequest_Frontend - // *PutSolutionRegistrationRequest_Backend - Half isPutSolutionRegistrationRequest_Half `protobuf_oneof:"half"` + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + TargetId string `protobuf:"bytes,4,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + Kind SolutionDeclaredKind `protobuf:"varint,5,opt,name=kind,proto3,enum=saas.accounts.v1.SolutionDeclaredKind" json:"kind,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *PutSolutionRegistrationRequest) Reset() { - *x = PutSolutionRegistrationRequest{} +func (x *SolutionDeclaredBinding) Reset() { + *x = SolutionDeclaredBinding{} mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *PutSolutionRegistrationRequest) String() string { +func (x *SolutionDeclaredBinding) String() string { return protoimpl.X.MessageStringOf(x) } -func (*PutSolutionRegistrationRequest) ProtoMessage() {} +func (*SolutionDeclaredBinding) ProtoMessage() {} -func (x *PutSolutionRegistrationRequest) ProtoReflect() protoreflect.Message { +func (x *SolutionDeclaredBinding) ProtoReflect() protoreflect.Message { mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -541,106 +559,153 @@ func (x *PutSolutionRegistrationRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use PutSolutionRegistrationRequest.ProtoReflect.Descriptor instead. -func (*PutSolutionRegistrationRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use SolutionDeclaredBinding.ProtoReflect.Descriptor instead. +func (*SolutionDeclaredBinding) Descriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{5} } -func (x *PutSolutionRegistrationRequest) GetSolutionId() string { +func (x *SolutionDeclaredBinding) GetBindingId() string { if x != nil { - return x.SolutionId + return x.BindingId } return "" } -func (x *PutSolutionRegistrationRequest) GetPublisher() string { +func (x *SolutionDeclaredBinding) GetGeneration() uint64 { if x != nil { - return x.Publisher - } - return "" -} - -func (x *PutSolutionRegistrationRequest) GetExpectedRevision() int64 { - if x != nil && x.ExpectedRevision != nil { - return *x.ExpectedRevision + return x.Generation } return 0 } -func (x *PutSolutionRegistrationRequest) GetLeaseSeconds() uint32 { +func (x *SolutionDeclaredBinding) GetRelease() string { if x != nil { - return x.LeaseSeconds + return x.Release } - return 0 + return "" } -func (x *PutSolutionRegistrationRequest) GetHalf() isPutSolutionRegistrationRequest_Half { +func (x *SolutionDeclaredBinding) GetTargetId() string { if x != nil { - return x.Half + return x.TargetId } - return nil + return "" } -func (x *PutSolutionRegistrationRequest) GetFrontend() *SolutionFrontendRegistration { +func (x *SolutionDeclaredBinding) GetKind() SolutionDeclaredKind { if x != nil { - if x, ok := x.Half.(*PutSolutionRegistrationRequest_Frontend); ok { - return x.Frontend - } + return x.Kind } - return nil + return SolutionDeclaredKind_SOLUTION_DECLARED_KIND_UNSPECIFIED +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +type SolutionHostBindingGeneration struct { + state protoimpl.MessageState `protogen:"open.v1"` + Generation uint64 `protobuf:"varint,1,opt,name=generation,proto3" json:"generation,omitempty"` + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + Digest string `protobuf:"bytes,2,opt,name=digest,proto3" json:"digest,omitempty"` + Document string `protobuf:"bytes,3,opt,name=document,proto3" json:"document,omitempty"` + At *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=at,proto3" json:"at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SolutionHostBindingGeneration) Reset() { + *x = SolutionHostBindingGeneration{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *PutSolutionRegistrationRequest) GetBackend() *SolutionBackendRegistration { +func (x *SolutionHostBindingGeneration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolutionHostBindingGeneration) ProtoMessage() {} + +func (x *SolutionHostBindingGeneration) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] if x != nil { - if x, ok := x.Half.(*PutSolutionRegistrationRequest_Backend); ok { - return x.Backend + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) } + return ms } - return nil + return mi.MessageOf(x) } -type isPutSolutionRegistrationRequest_Half interface { - isPutSolutionRegistrationRequest_Half() +// Deprecated: Use SolutionHostBindingGeneration.ProtoReflect.Descriptor instead. +func (*SolutionHostBindingGeneration) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{6} } -type PutSolutionRegistrationRequest_Frontend struct { - Frontend *SolutionFrontendRegistration `protobuf:"bytes,5,opt,name=frontend,proto3,oneof"` +func (x *SolutionHostBindingGeneration) GetGeneration() uint64 { + if x != nil { + return x.Generation + } + return 0 } -type PutSolutionRegistrationRequest_Backend struct { - Backend *SolutionBackendRegistration `protobuf:"bytes,6,opt,name=backend,proto3,oneof"` +func (x *SolutionHostBindingGeneration) GetDigest() string { + if x != nil { + return x.Digest + } + return "" } -func (*PutSolutionRegistrationRequest_Frontend) isPutSolutionRegistrationRequest_Half() {} +func (x *SolutionHostBindingGeneration) GetDocument() string { + if x != nil { + return x.Document + } + return "" +} -func (*PutSolutionRegistrationRequest_Backend) isPutSolutionRegistrationRequest_Half() {} +func (x *SolutionHostBindingGeneration) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -type DeleteSolutionRegistrationRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - SolutionId string `protobuf:"bytes,1,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` - ExpectedRevision *int64 `protobuf:"varint,2,opt,name=expected_revision,json=expectedRevision,proto3,oneof" json:"expected_revision,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +type SolutionHostBindingAppliedGeneration struct { + state protoimpl.MessageState `protogen:"open.v1"` + Generation *SolutionHostBindingGeneration `protobuf:"bytes,1,opt,name=generation,proto3" json:"generation,omitempty"` + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + Removed bool `protobuf:"varint,2,opt,name=removed,proto3" json:"removed,omitempty"` + // The route aliases the applied generation holds. A tombstone holds none. + Routes []string `protobuf:"bytes,3,rep,name=routes,proto3" json:"routes,omitempty"` + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + SolutionId string `protobuf:"bytes,4,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` + Release string `protobuf:"bytes,5,opt,name=release,proto3" json:"release,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *DeleteSolutionRegistrationRequest) Reset() { - *x = DeleteSolutionRegistrationRequest{} - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] +func (x *SolutionHostBindingAppliedGeneration) Reset() { + *x = SolutionHostBindingAppliedGeneration{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *DeleteSolutionRegistrationRequest) String() string { +func (x *SolutionHostBindingAppliedGeneration) String() string { return protoimpl.X.MessageStringOf(x) } -func (*DeleteSolutionRegistrationRequest) ProtoMessage() {} +func (*SolutionHostBindingAppliedGeneration) ProtoMessage() {} -func (x *DeleteSolutionRegistrationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] +func (x *SolutionHostBindingAppliedGeneration) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -651,52 +716,89 @@ func (x *DeleteSolutionRegistrationRequest) ProtoReflect() protoreflect.Message return mi.MessageOf(x) } -// Deprecated: Use DeleteSolutionRegistrationRequest.ProtoReflect.Descriptor instead. -func (*DeleteSolutionRegistrationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{6} +// Deprecated: Use SolutionHostBindingAppliedGeneration.ProtoReflect.Descriptor instead. +func (*SolutionHostBindingAppliedGeneration) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{7} +} + +func (x *SolutionHostBindingAppliedGeneration) GetGeneration() *SolutionHostBindingGeneration { + if x != nil { + return x.Generation + } + return nil } -func (x *DeleteSolutionRegistrationRequest) GetSolutionId() string { +func (x *SolutionHostBindingAppliedGeneration) GetRemoved() bool { + if x != nil { + return x.Removed + } + return false +} + +func (x *SolutionHostBindingAppliedGeneration) GetRoutes() []string { + if x != nil { + return x.Routes + } + return nil +} + +func (x *SolutionHostBindingAppliedGeneration) GetSolutionId() string { if x != nil { return x.SolutionId } return "" } -func (x *DeleteSolutionRegistrationRequest) GetExpectedRevision() int64 { - if x != nil && x.ExpectedRevision != nil { - return *x.ExpectedRevision +func (x *SolutionHostBindingAppliedGeneration) GetRelease() string { + if x != nil { + return x.Release } - return 0 + return "" } -// ListSolutionRegistrationsRequest reads the whole registry. There is one -// registry per deployment and it holds tens of records at most, so consumers -// rebuild their cache from a full snapshot rather than tailing a change feed. -type ListSolutionRegistrationsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Tombstones are omitted by default: a routing cache wants the live set. An - // operator diagnosing "was this deleted or never registered?" asks for them. - IncludeTombstoned bool `protobuf:"varint,1,opt,name=include_tombstoned,json=includeTombstoned,proto3" json:"include_tombstoned,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +type SolutionHostBindingState struct { + state protoimpl.MessageState `protogen:"open.v1"` + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + HostCoordinate string `protobuf:"bytes,2,opt,name=host_coordinate,json=hostCoordinate,proto3" json:"host_coordinate,omitempty"` + HostComponent string `protobuf:"bytes,3,opt,name=host_component,json=hostComponent,proto3" json:"host_component,omitempty"` + // The newest generation delivery has shown this host, whether or not it + // passed. + Desired *SolutionHostBindingGeneration `protobuf:"bytes,4,opt,name=desired,proto3,oneof" json:"desired,omitempty"` + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + Applied *SolutionHostBindingAppliedGeneration `protobuf:"bytes,5,opt,name=applied,proto3,oneof" json:"applied,omitempty"` + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + PendingGeneration uint64 `protobuf:"varint,6,opt,name=pending_generation,json=pendingGeneration,proto3" json:"pending_generation,omitempty"` + // Why the pending generation was not applied. Empty when nothing is pending. + PendingReason string `protobuf:"bytes,7,opt,name=pending_reason,json=pendingReason,proto3" json:"pending_reason,omitempty"` + PendingSince *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=pending_since,json=pendingSince,proto3,oneof" json:"pending_since,omitempty"` + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + Registration *SolutionRegistration `protobuf:"bytes,9,opt,name=registration,proto3,oneof" json:"registration,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *ListSolutionRegistrationsRequest) Reset() { - *x = ListSolutionRegistrationsRequest{} - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] +func (x *SolutionHostBindingState) Reset() { + *x = SolutionHostBindingState{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListSolutionRegistrationsRequest) String() string { +func (x *SolutionHostBindingState) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListSolutionRegistrationsRequest) ProtoMessage() {} +func (*SolutionHostBindingState) ProtoMessage() {} -func (x *ListSolutionRegistrationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] +func (x *SolutionHostBindingState) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -707,43 +809,105 @@ func (x *ListSolutionRegistrationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListSolutionRegistrationsRequest.ProtoReflect.Descriptor instead. -func (*ListSolutionRegistrationsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{7} +// Deprecated: Use SolutionHostBindingState.ProtoReflect.Descriptor instead. +func (*SolutionHostBindingState) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{8} } -func (x *ListSolutionRegistrationsRequest) GetIncludeTombstoned() bool { +func (x *SolutionHostBindingState) GetBindingId() string { if x != nil { - return x.IncludeTombstoned + return x.BindingId } - return false + return "" } -type ListSolutionRegistrationsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Registrations []*SolutionRegistration `protobuf:"bytes,1,rep,name=registrations,proto3" json:"registrations,omitempty"` - // Highest revision in the registry, tombstones included. Two consumers that - // report the same registry_revision have converged on the same state. - RegistryRevision int64 `protobuf:"varint,2,opt,name=registry_revision,json=registryRevision,proto3" json:"registry_revision,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *SolutionHostBindingState) GetHostCoordinate() string { + if x != nil { + return x.HostCoordinate + } + return "" } -func (x *ListSolutionRegistrationsResponse) Reset() { - *x = ListSolutionRegistrationsResponse{} - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] +func (x *SolutionHostBindingState) GetHostComponent() string { + if x != nil { + return x.HostComponent + } + return "" +} + +func (x *SolutionHostBindingState) GetDesired() *SolutionHostBindingGeneration { + if x != nil { + return x.Desired + } + return nil +} + +func (x *SolutionHostBindingState) GetApplied() *SolutionHostBindingAppliedGeneration { + if x != nil { + return x.Applied + } + return nil +} + +func (x *SolutionHostBindingState) GetPendingGeneration() uint64 { + if x != nil { + return x.PendingGeneration + } + return 0 +} + +func (x *SolutionHostBindingState) GetPendingReason() string { + if x != nil { + return x.PendingReason + } + return "" +} + +func (x *SolutionHostBindingState) GetPendingSince() *timestamppb.Timestamp { + if x != nil { + return x.PendingSince + } + return nil +} + +func (x *SolutionHostBindingState) GetRegistration() *SolutionRegistration { + if x != nil { + return x.Registration + } + return nil +} + +func (x *SolutionHostBindingState) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +type ListSolutionHostBindingsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionHostBindingsRequest) Reset() { + *x = ListSolutionHostBindingsRequest{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListSolutionRegistrationsResponse) String() string { +func (x *ListSolutionHostBindingsRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListSolutionRegistrationsResponse) ProtoMessage() {} +func (*ListSolutionHostBindingsRequest) ProtoMessage() {} -func (x *ListSolutionRegistrationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] +func (x *ListSolutionHostBindingsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -754,41 +918,69 @@ func (x *ListSolutionRegistrationsResponse) ProtoReflect() protoreflect.Message return mi.MessageOf(x) } -// Deprecated: Use ListSolutionRegistrationsResponse.ProtoReflect.Descriptor instead. -func (*ListSolutionRegistrationsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{8} +// Deprecated: Use ListSolutionHostBindingsRequest.ProtoReflect.Descriptor instead. +func (*ListSolutionHostBindingsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{9} } -func (x *ListSolutionRegistrationsResponse) GetRegistrations() []*SolutionRegistration { +type ListSolutionHostBindingsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Bindings []*SolutionHostBindingState `protobuf:"bytes,1,rep,name=bindings,proto3" json:"bindings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionHostBindingsResponse) Reset() { + *x = ListSolutionHostBindingsResponse{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSolutionHostBindingsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSolutionHostBindingsResponse) ProtoMessage() {} + +func (x *ListSolutionHostBindingsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[10] if x != nil { - return x.Registrations + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return nil + return mi.MessageOf(x) } -func (x *ListSolutionRegistrationsResponse) GetRegistryRevision() int64 { +// Deprecated: Use ListSolutionHostBindingsResponse.ProtoReflect.Descriptor instead. +func (*ListSolutionHostBindingsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{10} +} + +func (x *ListSolutionHostBindingsResponse) GetBindings() []*SolutionHostBindingState { if x != nil { - return x.RegistryRevision + return x.Bindings } - return 0 + return nil } var File_saas_accounts_v1_solution_registry_proto protoreflect.FileDescriptor const file_saas_accounts_v1_solution_registry_proto_rawDesc = "" + "\n" + - "(saas/accounts/v1/solution_registry.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc2\x01\n" + + "(saas/accounts/v1/solution_registry.proto\x12\x10saas.accounts.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x94\x01\n" + "\x17SolutionFrontendBinding\x12\x1a\n" + "\brevision\x18\x01 \x01(\x03R\brevision\x12\x1a\n" + "\bmanifest\x18\x02 \x01(\tR\bmanifest\x12)\n" + - "\x10contract_version\x18\x03 \x01(\tR\x0fcontractVersion\x12D\n" + - "\x10lease_expires_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0eleaseExpiresAt\"\xe6\x01\n" + + "\x10contract_version\x18\x03 \x01(\tR\x0fcontractVersionJ\x04\b\x04\x10\x05R\x10lease_expires_at\"\xb8\x01\n" + "\x16SolutionBackendBinding\x12\x1a\n" + "\brevision\x18\x01 \x01(\x03R\brevision\x12\x1a\n" + "\bupstream\x18\x02 \x01(\tR\bupstream\x12#\n" + "\rservice_alias\x18\x03 \x01(\tR\fserviceAlias\x12)\n" + - "\x10contract_version\x18\x04 \x01(\tR\x0fcontractVersion\x12D\n" + - "\x10lease_expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x0eleaseExpiresAt\"\xa3\x04\n" + + "\x10contract_version\x18\x04 \x01(\tR\x0fcontractVersionJ\x04\b\x05\x10\x06R\x10lease_expires_at\"\xe9\x04\n" + "\x14SolutionRegistration\x12\x1f\n" + "\vsolution_id\x18\x01 \x01(\tR\n" + "solutionId\x12\x1c\n" + @@ -799,51 +991,78 @@ const file_saas_accounts_v1_solution_registry_proto_rawDesc = "" + "\abackend\x18\x06 \x01(\v2(.saas.accounts.v1.SolutionBackendBindingH\x01R\abackend\x88\x01\x01\x129\n" + "\n" + "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12D\n" + - "\rtombstoned_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x02R\ftombstonedAt\x88\x01\x01\x12)\n" + - "\x10runtime_boundary\x18\t \x01(\tR\x0fruntimeBoundaryB\v\n" + + "\rtombstoned_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x02R\ftombstonedAt\x88\x01\x01\x12J\n" + + "\bdeclared\x18\n" + + " \x01(\v2).saas.accounts.v1.SolutionDeclaredBindingH\x03R\bdeclared\x88\x01\x01B\v\n" + "\t_frontendB\n" + "\n" + "\b_backendB\x10\n" + - "\x0e_tombstoned_at\"|\n" + - "\x1cSolutionFrontendRegistration\x12'\n" + - "\bmanifest\x18\x01 \x01(\tB\v\xbaH\br\x06\x10\x02\x18\x80\x80\x10R\bmanifest\x123\n" + - "\x10contract_version\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x01R\x0fcontractVersion\"\xae\x01\n" + - "\x1bSolutionBackendRegistration\x12)\n" + - "\bupstream\x18\x01 \x01(\tB\r\xbaH\n" + - "r\b\x10\x01\x18\x80\x10\x88\x01\x01R\bupstream\x12/\n" + - "\rservice_alias\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x01R\fserviceAlias\x123\n" + - "\x10contract_version\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x01R\x0fcontractVersion\"\xc5\x03\n" + - "\x1ePutSolutionRegistrationRequest\x12O\n" + - "\vsolution_id\x18\x01 \x01(\tB.\xbaH+r)\x10\x01\x18\x80\x012\"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$R\n" + - "solutionId\x12(\n" + - "\tpublisher\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x02R\tpublisher\x129\n" + - "\x11expected_revision\x18\x03 \x01(\x03B\a\xbaH\x04\"\x02 \x00H\x01R\x10expectedRevision\x88\x01\x01\x12/\n" + - "\rlease_seconds\x18\x04 \x01(\rB\n" + - "\xbaH\a*\x05\x18\x90\x1c(\x1eR\fleaseSeconds\x12L\n" + - "\bfrontend\x18\x05 \x01(\v2..saas.accounts.v1.SolutionFrontendRegistrationH\x00R\bfrontend\x12I\n" + - "\abackend\x18\x06 \x01(\v2-.saas.accounts.v1.SolutionBackendRegistrationH\x00R\abackendB\r\n" + - "\x04half\x12\x05\xbaH\x02\b\x01B\x14\n" + - "\x12_expected_revision\"\xa1\x01\n" + - "!DeleteSolutionRegistrationRequest\x12+\n" + - "\vsolution_id\x18\x01 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x01R\n" + - "solutionId\x129\n" + - "\x11expected_revision\x18\x02 \x01(\x03B\a\xbaH\x04\"\x02 \x00H\x00R\x10expectedRevision\x88\x01\x01B\x14\n" + - "\x12_expected_revision\"Q\n" + + "\x0e_tombstoned_atB\v\n" + + "\t_declaredJ\x04\b\t\x10\n" + + "R\x10runtime_boundary\"Q\n" + " ListSolutionRegistrationsRequest\x12-\n" + "\x12include_tombstoned\x18\x01 \x01(\bR\x11includeTombstoned\"\x9e\x01\n" + "!ListSolutionRegistrationsResponse\x12L\n" + "\rregistrations\x18\x01 \x03(\v2&.saas.accounts.v1.SolutionRegistrationR\rregistrations\x12+\n" + - "\x11registry_revision\x18\x02 \x01(\x03R\x10registryRevision*\xa3\x02\n" + + "\x11registry_revision\x18\x02 \x01(\x03R\x10registryRevision\"\xcb\x01\n" + + "\x17SolutionDeclaredBinding\x12\x1d\n" + + "\n" + + "binding_id\x18\x01 \x01(\tR\tbindingId\x12\x1e\n" + + "\n" + + "generation\x18\x02 \x01(\x04R\n" + + "generation\x12\x18\n" + + "\arelease\x18\x03 \x01(\tR\arelease\x12\x1b\n" + + "\ttarget_id\x18\x04 \x01(\tR\btargetId\x12:\n" + + "\x04kind\x18\x05 \x01(\x0e2&.saas.accounts.v1.SolutionDeclaredKindR\x04kind\"\x9f\x01\n" + + "\x1dSolutionHostBindingGeneration\x12\x1e\n" + + "\n" + + "generation\x18\x01 \x01(\x04R\n" + + "generation\x12\x16\n" + + "\x06digest\x18\x02 \x01(\tR\x06digest\x12\x1a\n" + + "\bdocument\x18\x03 \x01(\tR\bdocument\x12*\n" + + "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"\xe4\x01\n" + + "$SolutionHostBindingAppliedGeneration\x12O\n" + + "\n" + + "generation\x18\x01 \x01(\v2/.saas.accounts.v1.SolutionHostBindingGenerationR\n" + + "generation\x12\x18\n" + + "\aremoved\x18\x02 \x01(\bR\aremoved\x12\x16\n" + + "\x06routes\x18\x03 \x03(\tR\x06routes\x12\x1f\n" + + "\vsolution_id\x18\x04 \x01(\tR\n" + + "solutionId\x12\x18\n" + + "\arelease\x18\x05 \x01(\tR\arelease\"\x93\x05\n" + + "\x18SolutionHostBindingState\x12\x1d\n" + + "\n" + + "binding_id\x18\x01 \x01(\tR\tbindingId\x12'\n" + + "\x0fhost_coordinate\x18\x02 \x01(\tR\x0ehostCoordinate\x12%\n" + + "\x0ehost_component\x18\x03 \x01(\tR\rhostComponent\x12N\n" + + "\adesired\x18\x04 \x01(\v2/.saas.accounts.v1.SolutionHostBindingGenerationH\x00R\adesired\x88\x01\x01\x12U\n" + + "\aapplied\x18\x05 \x01(\v26.saas.accounts.v1.SolutionHostBindingAppliedGenerationH\x01R\aapplied\x88\x01\x01\x12-\n" + + "\x12pending_generation\x18\x06 \x01(\x04R\x11pendingGeneration\x12%\n" + + "\x0epending_reason\x18\a \x01(\tR\rpendingReason\x12D\n" + + "\rpending_since\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x02R\fpendingSince\x88\x01\x01\x12O\n" + + "\fregistration\x18\t \x01(\v2&.saas.accounts.v1.SolutionRegistrationH\x03R\fregistration\x88\x01\x01\x129\n" + + "\n" + + "updated_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\n" + + "\n" + + "\b_desiredB\n" + + "\n" + + "\b_appliedB\x10\n" + + "\x0e_pending_sinceB\x0f\n" + + "\r_registration\"!\n" + + "\x1fListSolutionHostBindingsRequest\"j\n" + + " ListSolutionHostBindingsResponse\x12F\n" + + "\bbindings\x18\x01 \x03(\v2*.saas.accounts.v1.SolutionHostBindingStateR\bbindings*\xa5\x02\n" + "\x1aSolutionRegistrationStatus\x12,\n" + "(SOLUTION_REGISTRATION_STATUS_UNSPECIFIED\x10\x00\x12'\n" + "#SOLUTION_REGISTRATION_STATUS_ACTIVE\x10\x01\x12(\n" + - "$SOLUTION_REGISTRATION_STATUS_PENDING\x10\x02\x12(\n" + - "$SOLUTION_REGISTRATION_STATUS_EXPIRED\x10\x03\x12-\n" + + "$SOLUTION_REGISTRATION_STATUS_PENDING\x10\x02\x12-\n" + ")SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE\x10\x04\x12+\n" + - "'SOLUTION_REGISTRATION_STATUS_TOMBSTONED\x10\x05B\xbd\x01\n" + + "'SOLUTION_REGISTRATION_STATUS_TOMBSTONED\x10\x05\"\x04\b\x03\x10\x03*$SOLUTION_REGISTRATION_STATUS_EXPIRED*\x86\x01\n" + + "\x14SolutionDeclaredKind\x12&\n" + + "\"SOLUTION_DECLARED_KIND_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fSOLUTION_DECLARED_KIND_SOLUTION\x10\x01\x12!\n" + + "\x1dSOLUTION_DECLARED_KIND_MODULE\x10\x02B\xbd\x01\n" + "\x14com.saas.accounts.v1B\x15SolutionRegistryProtoP\x01Z,accounts/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" var ( @@ -858,37 +1077,46 @@ func file_saas_accounts_v1_solution_registry_proto_rawDescGZIP() []byte { return file_saas_accounts_v1_solution_registry_proto_rawDescData } -var file_saas_accounts_v1_solution_registry_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_saas_accounts_v1_solution_registry_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_saas_accounts_v1_solution_registry_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_saas_accounts_v1_solution_registry_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_saas_accounts_v1_solution_registry_proto_goTypes = []any{ - (SolutionRegistrationStatus)(0), // 0: saas.accounts.v1.SolutionRegistrationStatus - (*SolutionFrontendBinding)(nil), // 1: saas.accounts.v1.SolutionFrontendBinding - (*SolutionBackendBinding)(nil), // 2: saas.accounts.v1.SolutionBackendBinding - (*SolutionRegistration)(nil), // 3: saas.accounts.v1.SolutionRegistration - (*SolutionFrontendRegistration)(nil), // 4: saas.accounts.v1.SolutionFrontendRegistration - (*SolutionBackendRegistration)(nil), // 5: saas.accounts.v1.SolutionBackendRegistration - (*PutSolutionRegistrationRequest)(nil), // 6: saas.accounts.v1.PutSolutionRegistrationRequest - (*DeleteSolutionRegistrationRequest)(nil), // 7: saas.accounts.v1.DeleteSolutionRegistrationRequest - (*ListSolutionRegistrationsRequest)(nil), // 8: saas.accounts.v1.ListSolutionRegistrationsRequest - (*ListSolutionRegistrationsResponse)(nil), // 9: saas.accounts.v1.ListSolutionRegistrationsResponse - (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (SolutionRegistrationStatus)(0), // 0: saas.accounts.v1.SolutionRegistrationStatus + (SolutionDeclaredKind)(0), // 1: saas.accounts.v1.SolutionDeclaredKind + (*SolutionFrontendBinding)(nil), // 2: saas.accounts.v1.SolutionFrontendBinding + (*SolutionBackendBinding)(nil), // 3: saas.accounts.v1.SolutionBackendBinding + (*SolutionRegistration)(nil), // 4: saas.accounts.v1.SolutionRegistration + (*ListSolutionRegistrationsRequest)(nil), // 5: saas.accounts.v1.ListSolutionRegistrationsRequest + (*ListSolutionRegistrationsResponse)(nil), // 6: saas.accounts.v1.ListSolutionRegistrationsResponse + (*SolutionDeclaredBinding)(nil), // 7: saas.accounts.v1.SolutionDeclaredBinding + (*SolutionHostBindingGeneration)(nil), // 8: saas.accounts.v1.SolutionHostBindingGeneration + (*SolutionHostBindingAppliedGeneration)(nil), // 9: saas.accounts.v1.SolutionHostBindingAppliedGeneration + (*SolutionHostBindingState)(nil), // 10: saas.accounts.v1.SolutionHostBindingState + (*ListSolutionHostBindingsRequest)(nil), // 11: saas.accounts.v1.ListSolutionHostBindingsRequest + (*ListSolutionHostBindingsResponse)(nil), // 12: saas.accounts.v1.ListSolutionHostBindingsResponse + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp } var file_saas_accounts_v1_solution_registry_proto_depIdxs = []int32{ - 10, // 0: saas.accounts.v1.SolutionFrontendBinding.lease_expires_at:type_name -> google.protobuf.Timestamp - 10, // 1: saas.accounts.v1.SolutionBackendBinding.lease_expires_at:type_name -> google.protobuf.Timestamp - 0, // 2: saas.accounts.v1.SolutionRegistration.status:type_name -> saas.accounts.v1.SolutionRegistrationStatus - 1, // 3: saas.accounts.v1.SolutionRegistration.frontend:type_name -> saas.accounts.v1.SolutionFrontendBinding - 2, // 4: saas.accounts.v1.SolutionRegistration.backend:type_name -> saas.accounts.v1.SolutionBackendBinding - 10, // 5: saas.accounts.v1.SolutionRegistration.updated_at:type_name -> google.protobuf.Timestamp - 10, // 6: saas.accounts.v1.SolutionRegistration.tombstoned_at:type_name -> google.protobuf.Timestamp - 4, // 7: saas.accounts.v1.PutSolutionRegistrationRequest.frontend:type_name -> saas.accounts.v1.SolutionFrontendRegistration - 5, // 8: saas.accounts.v1.PutSolutionRegistrationRequest.backend:type_name -> saas.accounts.v1.SolutionBackendRegistration - 3, // 9: saas.accounts.v1.ListSolutionRegistrationsResponse.registrations:type_name -> saas.accounts.v1.SolutionRegistration - 10, // [10:10] is the sub-list for method output_type - 10, // [10:10] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 0, // 0: saas.accounts.v1.SolutionRegistration.status:type_name -> saas.accounts.v1.SolutionRegistrationStatus + 2, // 1: saas.accounts.v1.SolutionRegistration.frontend:type_name -> saas.accounts.v1.SolutionFrontendBinding + 3, // 2: saas.accounts.v1.SolutionRegistration.backend:type_name -> saas.accounts.v1.SolutionBackendBinding + 13, // 3: saas.accounts.v1.SolutionRegistration.updated_at:type_name -> google.protobuf.Timestamp + 13, // 4: saas.accounts.v1.SolutionRegistration.tombstoned_at:type_name -> google.protobuf.Timestamp + 7, // 5: saas.accounts.v1.SolutionRegistration.declared:type_name -> saas.accounts.v1.SolutionDeclaredBinding + 4, // 6: saas.accounts.v1.ListSolutionRegistrationsResponse.registrations:type_name -> saas.accounts.v1.SolutionRegistration + 1, // 7: saas.accounts.v1.SolutionDeclaredBinding.kind:type_name -> saas.accounts.v1.SolutionDeclaredKind + 13, // 8: saas.accounts.v1.SolutionHostBindingGeneration.at:type_name -> google.protobuf.Timestamp + 8, // 9: saas.accounts.v1.SolutionHostBindingAppliedGeneration.generation:type_name -> saas.accounts.v1.SolutionHostBindingGeneration + 8, // 10: saas.accounts.v1.SolutionHostBindingState.desired:type_name -> saas.accounts.v1.SolutionHostBindingGeneration + 9, // 11: saas.accounts.v1.SolutionHostBindingState.applied:type_name -> saas.accounts.v1.SolutionHostBindingAppliedGeneration + 13, // 12: saas.accounts.v1.SolutionHostBindingState.pending_since:type_name -> google.protobuf.Timestamp + 4, // 13: saas.accounts.v1.SolutionHostBindingState.registration:type_name -> saas.accounts.v1.SolutionRegistration + 13, // 14: saas.accounts.v1.SolutionHostBindingState.updated_at:type_name -> google.protobuf.Timestamp + 10, // 15: saas.accounts.v1.ListSolutionHostBindingsResponse.bindings:type_name -> saas.accounts.v1.SolutionHostBindingState + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_saas_accounts_v1_solution_registry_proto_init() } @@ -897,18 +1125,14 @@ func file_saas_accounts_v1_solution_registry_proto_init() { return } file_saas_accounts_v1_solution_registry_proto_msgTypes[2].OneofWrappers = []any{} - file_saas_accounts_v1_solution_registry_proto_msgTypes[5].OneofWrappers = []any{ - (*PutSolutionRegistrationRequest_Frontend)(nil), - (*PutSolutionRegistrationRequest_Backend)(nil), - } - file_saas_accounts_v1_solution_registry_proto_msgTypes[6].OneofWrappers = []any{} + file_saas_accounts_v1_solution_registry_proto_msgTypes[8].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_registry_proto_rawDesc), len(file_saas_accounts_v1_solution_registry_proto_rawDesc)), - NumEnums: 1, - NumMessages: 9, + NumEnums: 2, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service.pb.go index 974423596..f73853a16 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service.pb.go @@ -26,31 +26,25 @@ var File_saas_accounts_v1_solution_registry_service_proto protoreflect.FileDescr const file_saas_accounts_v1_solution_registry_service_proto_rawDesc = "" + "\n" + - "0saas/accounts/v1/solution_registry_service.proto\x12\x10saas.accounts.v1\x1a(saas/accounts/v1/solution_registry.proto\x1a\x1csaas/policy/v1/options.proto2\xa8\x04\n" + - "\x17SolutionRegistryService\x12\xb1\x01\n" + - "\x17PutSolutionRegistration\x120.saas.accounts.v1.PutSolutionRegistrationRequest\x1a&.saas.accounts.v1.SolutionRegistration\"<\xc2\xf3\x188\b\x03\x10\x010\x01:&\n" + - "\"saas.solution.registration_updated\x10\x02@\x01H\aP\x03X\x03`\x01\x12\xb7\x01\n" + - "\x1aDeleteSolutionRegistration\x123.saas.accounts.v1.DeleteSolutionRegistrationRequest\x1a&.saas.accounts.v1.SolutionRegistration\"<\xc2\xf3\x188\b\x03\x10\x010\x01:&\n" + - "\"saas.solution.registration_deleted\x10\x02@\x01H\aP\x03X\x03`\x01\x12\x9e\x01\n" + + "0saas/accounts/v1/solution_registry_service.proto\x12\x10saas.accounts.v1\x1a(saas/accounts/v1/solution_registry.proto\x1a\x1csaas/policy/v1/options.proto2\xd8\x02\n" + + "\x17SolutionRegistryService\x12\x9b\x01\n" + + "\x18ListSolutionHostBindings\x121.saas.accounts.v1.ListSolutionHostBindingsRequest\x1a2.saas.accounts.v1.ListSolutionHostBindingsResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x9e\x01\n" + "\x19ListSolutionRegistrations\x122.saas.accounts.v1.ListSolutionRegistrationsRequest\x1a3.saas.accounts.v1.ListSolutionRegistrationsResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01B\xc4\x01\n" + "\x14com.saas.accounts.v1B\x1cSolutionRegistryServiceProtoP\x01Z,accounts/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" var file_saas_accounts_v1_solution_registry_service_proto_goTypes = []any{ - (*PutSolutionRegistrationRequest)(nil), // 0: saas.accounts.v1.PutSolutionRegistrationRequest - (*DeleteSolutionRegistrationRequest)(nil), // 1: saas.accounts.v1.DeleteSolutionRegistrationRequest - (*ListSolutionRegistrationsRequest)(nil), // 2: saas.accounts.v1.ListSolutionRegistrationsRequest - (*SolutionRegistration)(nil), // 3: saas.accounts.v1.SolutionRegistration - (*ListSolutionRegistrationsResponse)(nil), // 4: saas.accounts.v1.ListSolutionRegistrationsResponse + (*ListSolutionHostBindingsRequest)(nil), // 0: saas.accounts.v1.ListSolutionHostBindingsRequest + (*ListSolutionRegistrationsRequest)(nil), // 1: saas.accounts.v1.ListSolutionRegistrationsRequest + (*ListSolutionHostBindingsResponse)(nil), // 2: saas.accounts.v1.ListSolutionHostBindingsResponse + (*ListSolutionRegistrationsResponse)(nil), // 3: saas.accounts.v1.ListSolutionRegistrationsResponse } var file_saas_accounts_v1_solution_registry_service_proto_depIdxs = []int32{ - 0, // 0: saas.accounts.v1.SolutionRegistryService.PutSolutionRegistration:input_type -> saas.accounts.v1.PutSolutionRegistrationRequest - 1, // 1: saas.accounts.v1.SolutionRegistryService.DeleteSolutionRegistration:input_type -> saas.accounts.v1.DeleteSolutionRegistrationRequest - 2, // 2: saas.accounts.v1.SolutionRegistryService.ListSolutionRegistrations:input_type -> saas.accounts.v1.ListSolutionRegistrationsRequest - 3, // 3: saas.accounts.v1.SolutionRegistryService.PutSolutionRegistration:output_type -> saas.accounts.v1.SolutionRegistration - 3, // 4: saas.accounts.v1.SolutionRegistryService.DeleteSolutionRegistration:output_type -> saas.accounts.v1.SolutionRegistration - 4, // 5: saas.accounts.v1.SolutionRegistryService.ListSolutionRegistrations:output_type -> saas.accounts.v1.ListSolutionRegistrationsResponse - 3, // [3:6] is the sub-list for method output_type - 0, // [0:3] is the sub-list for method input_type + 0, // 0: saas.accounts.v1.SolutionRegistryService.ListSolutionHostBindings:input_type -> saas.accounts.v1.ListSolutionHostBindingsRequest + 1, // 1: saas.accounts.v1.SolutionRegistryService.ListSolutionRegistrations:input_type -> saas.accounts.v1.ListSolutionRegistrationsRequest + 2, // 2: saas.accounts.v1.SolutionRegistryService.ListSolutionHostBindings:output_type -> saas.accounts.v1.ListSolutionHostBindingsResponse + 3, // 3: saas.accounts.v1.SolutionRegistryService.ListSolutionRegistrations:output_type -> saas.accounts.v1.ListSolutionRegistrationsResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service_grpc.pb.go index eb31d202e..5322b2382 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_registry_service_grpc.pb.go @@ -20,25 +20,23 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - SolutionRegistryService_PutSolutionRegistration_FullMethodName = "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" - SolutionRegistryService_DeleteSolutionRegistration_FullMethodName = "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration" - SolutionRegistryService_ListSolutionRegistrations_FullMethodName = "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" + SolutionRegistryService_ListSolutionHostBindings_FullMethodName = "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings" + SolutionRegistryService_ListSolutionRegistrations_FullMethodName = "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" ) // SolutionRegistryServiceClient is the client API for SolutionRegistryService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// SolutionRegistryService is the durable authority behind both self-registration -// surfaces. Every method is internal: the gateway is the only client, and it -// brokers the frontend's half as well as its own. +// Internal read service for declared solution state. The gateway is the client +// and brokers the registry projection to the frontend. type SolutionRegistryServiceClient interface { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - PutSolutionRegistration(ctx context.Context, in *PutSolutionRegistrationRequest, opts ...grpc.CallOption) (*SolutionRegistration, error) - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - DeleteSolutionRegistration(ctx context.Context, in *DeleteSolutionRegistrationRequest, opts ...grpc.CallOption) (*SolutionRegistration, error) + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + ListSolutionHostBindings(ctx context.Context, in *ListSolutionHostBindingsRequest, opts ...grpc.CallOption) (*ListSolutionHostBindingsResponse, error) // ListSolutionRegistrations returns the whole registry so a restarted or // lagging replica can rebuild its routing cache from authoritative state. ListSolutionRegistrations(ctx context.Context, in *ListSolutionRegistrationsRequest, opts ...grpc.CallOption) (*ListSolutionRegistrationsResponse, error) @@ -52,20 +50,10 @@ func NewSolutionRegistryServiceClient(cc grpc.ClientConnInterface) SolutionRegis return &solutionRegistryServiceClient{cc} } -func (c *solutionRegistryServiceClient) PutSolutionRegistration(ctx context.Context, in *PutSolutionRegistrationRequest, opts ...grpc.CallOption) (*SolutionRegistration, error) { +func (c *solutionRegistryServiceClient) ListSolutionHostBindings(ctx context.Context, in *ListSolutionHostBindingsRequest, opts ...grpc.CallOption) (*ListSolutionHostBindingsResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SolutionRegistration) - err := c.cc.Invoke(ctx, SolutionRegistryService_PutSolutionRegistration_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *solutionRegistryServiceClient) DeleteSolutionRegistration(ctx context.Context, in *DeleteSolutionRegistrationRequest, opts ...grpc.CallOption) (*SolutionRegistration, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SolutionRegistration) - err := c.cc.Invoke(ctx, SolutionRegistryService_DeleteSolutionRegistration_FullMethodName, in, out, cOpts...) + out := new(ListSolutionHostBindingsResponse) + err := c.cc.Invoke(ctx, SolutionRegistryService_ListSolutionHostBindings_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -86,16 +74,15 @@ func (c *solutionRegistryServiceClient) ListSolutionRegistrations(ctx context.Co // All implementations must embed UnimplementedSolutionRegistryServiceServer // for forward compatibility. // -// SolutionRegistryService is the durable authority behind both self-registration -// surfaces. Every method is internal: the gateway is the only client, and it -// brokers the frontend's half as well as its own. +// Internal read service for declared solution state. The gateway is the client +// and brokers the registry projection to the frontend. type SolutionRegistryServiceServer interface { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - PutSolutionRegistration(context.Context, *PutSolutionRegistrationRequest) (*SolutionRegistration, error) - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - DeleteSolutionRegistration(context.Context, *DeleteSolutionRegistrationRequest) (*SolutionRegistration, error) + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + ListSolutionHostBindings(context.Context, *ListSolutionHostBindingsRequest) (*ListSolutionHostBindingsResponse, error) // ListSolutionRegistrations returns the whole registry so a restarted or // lagging replica can rebuild its routing cache from authoritative state. ListSolutionRegistrations(context.Context, *ListSolutionRegistrationsRequest) (*ListSolutionRegistrationsResponse, error) @@ -109,11 +96,8 @@ type SolutionRegistryServiceServer interface { // pointer dereference when methods are called. type UnimplementedSolutionRegistryServiceServer struct{} -func (UnimplementedSolutionRegistryServiceServer) PutSolutionRegistration(context.Context, *PutSolutionRegistrationRequest) (*SolutionRegistration, error) { - return nil, status.Error(codes.Unimplemented, "method PutSolutionRegistration not implemented") -} -func (UnimplementedSolutionRegistryServiceServer) DeleteSolutionRegistration(context.Context, *DeleteSolutionRegistrationRequest) (*SolutionRegistration, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteSolutionRegistration not implemented") +func (UnimplementedSolutionRegistryServiceServer) ListSolutionHostBindings(context.Context, *ListSolutionHostBindingsRequest) (*ListSolutionHostBindingsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSolutionHostBindings not implemented") } func (UnimplementedSolutionRegistryServiceServer) ListSolutionRegistrations(context.Context, *ListSolutionRegistrationsRequest) (*ListSolutionRegistrationsResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListSolutionRegistrations not implemented") @@ -140,38 +124,20 @@ func RegisterSolutionRegistryServiceServer(s grpc.ServiceRegistrar, srv Solution s.RegisterService(&SolutionRegistryService_ServiceDesc, srv) } -func _SolutionRegistryService_PutSolutionRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(PutSolutionRegistrationRequest) +func _SolutionRegistryService_ListSolutionHostBindings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSolutionHostBindingsRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(SolutionRegistryServiceServer).PutSolutionRegistration(ctx, in) + return srv.(SolutionRegistryServiceServer).ListSolutionHostBindings(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: SolutionRegistryService_PutSolutionRegistration_FullMethodName, + FullMethod: SolutionRegistryService_ListSolutionHostBindings_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SolutionRegistryServiceServer).PutSolutionRegistration(ctx, req.(*PutSolutionRegistrationRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _SolutionRegistryService_DeleteSolutionRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteSolutionRegistrationRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(SolutionRegistryServiceServer).DeleteSolutionRegistration(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: SolutionRegistryService_DeleteSolutionRegistration_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(SolutionRegistryServiceServer).DeleteSolutionRegistration(ctx, req.(*DeleteSolutionRegistrationRequest)) + return srv.(SolutionRegistryServiceServer).ListSolutionHostBindings(ctx, req.(*ListSolutionHostBindingsRequest)) } return interceptor(ctx, in, info, handler) } @@ -202,12 +168,8 @@ var SolutionRegistryService_ServiceDesc = grpc.ServiceDesc{ HandlerType: (*SolutionRegistryServiceServer)(nil), Methods: []grpc.MethodDesc{ { - MethodName: "PutSolutionRegistration", - Handler: _SolutionRegistryService_PutSolutionRegistration_Handler, - }, - { - MethodName: "DeleteSolutionRegistration", - Handler: _SolutionRegistryService_DeleteSolutionRegistration_Handler, + MethodName: "ListSolutionHostBindings", + Handler: _SolutionRegistryService_ListSolutionHostBindings_Handler, }, { MethodName: "ListSolutionRegistrations", diff --git a/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go b/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go new file mode 100644 index 000000000..6c9e2a3cb --- /dev/null +++ b/module/services/accounts/code/pkg/infra/approved_build_reconcile_test.go @@ -0,0 +1,452 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// THE APPROVED-BUILD VIEW, from the inbox the delivery endpoint writes. +// +// The defect these cover is an absence, like activation's: `MonotonicApprovedBuilds` +// held the authoritative view, enforced the ordering rule over it, and NOTHING +// EVER WROTE TO IT. Every principal was therefore unknown, `ApprovedBuild` +// refused every one, and so `BindExecution` — the whole execution-binding +// mechanism, built and unit-tested — could not answer a single question on a +// running host. A reviewer reading those unit tests and that guard concludes the +// check is enforced. +// +// Driven through the REAL delivery handler and read back out of real Postgres, +// so what is under test is the host's path from "a carrier arrived" to "this +// principal is approved for this build" — not a hand-assembled document handed +// to the reconciler, which would prove only that the reconciler reads a struct. +// +// THE INBOX IS HOST-WIDE, and that is not a test artifact — it is the fact that +// shaped the design. Every test in this package delivers into one inbox, and so +// does every solution in a running host. A pass therefore always reads +// documents it is not about, and `RunOnce` returns the reasons for every +// principal it refused, including other solutions'. So these tests assert on +// the VIEW, which is the outcome, and check separately that their own +// principals are not among the refusals. A test that required RunOnce to return +// no error at all would be asserting that no other solution ever delivers +// anything questionable. + +// TestDeliveredAuthorityPopulatesTheApprovedBuildView is the regression test for +// the absence: before a pass the principal is UNKNOWN, after one it is approved +// for exactly what the delivered document approves. +func TestDeliveredAuthorityPopulatesTheApprovedBuildView(t *testing.T) { + scenario := newApprovedBuildScenario(t, "abv") + + // Before any pass: REFUSED, and refused as "no pass has completed" rather + // than as "unknown principal". A host that has reconciled nothing must not + // mint unbound capabilities for anyone, which is unchanged; what changed is + // that it no longer reports a cold start as a principal it has never heard + // of, because that sends an operator to correct a declaration that is + // already right. + // + // ADAPTED, not reverted: this is a DB-gated test, so it did not run when + // the distinction was introduced, and it is the test that proves the new + // sentinel reaches the real reconciler rather than only the constructed one. + _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.ErrorIs(t, err, business.ErrExecutionAuthorityUnreconciled, + "an unreconciled host must refuse, because an unbound capability for an unknown identity is the permissive answer") + require.NotErrorIs(t, err, business.ErrUnknownExecutionPrincipal, + "and it must not blame the principal for the host still starting") + + scenario.deliverAuthority(t, scenario.authority(t, "a", 3, false, scenario.principal)) + scenario.runPass(t) + + digest, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.NoError(t, err) + require.Equal(t, business.ApprovedDigest(scenario.build), digest, + "the approved digest is the signed document's, which is the one input the caller has no influence over") + require.Equal(t, uint64(3), incarnation, + "the incarnation is the authority document's generation, which core guarantees is strictly monotonic per authority id") +} + +// TestWithdrawnAuthorityMakesThePrincipalUnknownAgain is why the view is +// RECONSTRUCTED rather than written to. +// +// `MonotonicApprovedBuilds` has Approve and Declare and no third verb, so a +// withdrawal cannot be recorded into it — and the answer a withdrawal must +// produce is "unknown", not "bears no execution". Those are the two outcomes it +// would be easy to conflate and only one is safe: "bears none" mints a +// capability carrying no execution that every verifier accepts, which is right +// for a human session and wrong for a module whose authority was withdrawn. +func TestWithdrawnAuthorityMakesThePrincipalUnknownAgain(t *testing.T) { + scenario := newApprovedBuildScenario(t, "abw") + + scenario.deliverAuthority(t, scenario.authority(t, "a", 1, false, scenario.principal)) + scenario.runPass(t) + _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.NoError(t, err, "the grant must take effect before its withdrawal can mean anything") + + scenario.deliverAuthority(t, scenario.authority(t, "a", 2, true, scenario.principal)) + scenario.runPass(t) + + _, _, err = scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, + "a withdrawn authority must leave the principal UNKNOWN; answering ErrNoApprovedBuild would let it mint an unbound capability") +} + +// TestARewindAcrossPassesIsRefused is the test a fresh instance per pass cannot +// pass, and the reason the high-water marks outlive the view. +// +// Core's guidance is that a source which must rewind is reconstructing state and +// belongs behind a fresh instance. Applied naively that loses the guard: a fresh +// instance holds no previous record, so each pass accepts its own input as the +// first thing it ever saw, and generation 3 followed by generation 2 is accepted +// twice. The ordering rule is a property of the sequence of PASSES. +func TestARewindAcrossPassesIsRefused(t *testing.T) { + scenario := newApprovedBuildScenario(t, "abr") + + scenario.deliverAuthority(t, scenario.authority(t, "a", 3, false, scenario.principal)) + scenario.runPass(t) + _, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.NoError(t, err) + require.Equal(t, uint64(3), incarnation) + + // The inbox keeps the NEWEST generation per document, so a lower generation + // has to arrive under a different authority id to be in the inbox at all — + // which is also the shape that matters, since generations are monotonic per + // authority id and two ids are not comparable. Withdraw the first so the + // pass is not merely ambiguous, then deliver the lower one. + scenario.deliverAuthority(t, scenario.authority(t, "a", 4, true, scenario.principal)) + scenario.deliverAuthority(t, scenario.authority(t, "b", 2, false, scenario.principal)) + + reasons := scenario.reconciler.RunOnce(testCtx) + require.ErrorIs(t, reasons, business.ErrApprovedBuildRewind, + "a generation below the high-water mark must be refused, not accepted as a fresh start") + require.Contains(t, reasons.Error(), scenario.principal, + "the refusal must name the principal it is about, or an operator cannot act on it") + + // AND THE PRINCIPAL IS NOW UNKNOWN rather than still serving generation 3. + // A rewind means delivery no longer claims the generation this host served, + // so continuing to serve it would be answering with something nothing + // claims. Omitted is refused, which is the safe direction. + _, _, err = scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal) +} + +// TestAPrincipalGrantedByTwoLiveAuthoritiesIsRefused covers the catch in using +// the generation as the incarnation. +// +// Generations are monotonic PER AUTHORITY ID, so two documents with different +// ids have independent sequences. If both name one principal their numbers are +// incomparable: the view would flap between them, and whenever the numbers +// happen to ascend the principal is silently resealed to the other document's +// build with no counter having gone backwards for the guard to catch. +func TestAPrincipalGrantedByTwoLiveAuthoritiesIsRefused(t *testing.T) { + scenario := newApprovedBuildScenario(t, "aba") + + scenario.deliverAuthority(t, scenario.authority(t, "a", 1, false, scenario.principal)) + scenario.deliverAuthority(t, scenario.authority(t, "b", 1, false, scenario.principal)) + + reasons := scenario.reconciler.RunOnce(testCtx) + require.ErrorIs(t, reasons, business.ErrApprovedBuildAmbiguous, + "two live authority documents granting to one principal have no single generation, so this is refused rather than resolved") + require.Contains(t, reasons.Error(), scenario.principal) + + _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal) +} + +// TestOneRefusedPrincipalDoesNotWithholdTheOthers is the correction a test +// found, and it is the reason the refusals are per principal. +// +// The first version of this reconciler installed NOTHING when any principal was +// refused, on the argument that installing the coherent part of an incoherent +// inbox is how a withheld document becomes a quietly narrower system. The inbox +// is host-wide, so that made one solution's bad delivery an outage for every +// module on the host — while being no safer for the principal actually in +// doubt, which is refused either way. +func TestOneRefusedPrincipalDoesNotWithholdTheOthers(t *testing.T) { + // The second principal is named to the constructor so its grant is in the + // ceiling the reconciler holds; see newApprovedBuildScenario. + innocent := "principal:operator:" + uniqueAlias("abp") + ".innocent" + scenario := newApprovedBuildScenario(t, "abp", innocent) + + // The scenario's own principal granted twice — ambiguous — and a second + // principal granted once and correctly, in the same inbox. + scenario.deliverAuthority(t, scenario.authority(t, "a", 1, false, scenario.principal)) + scenario.deliverAuthority(t, scenario.authority(t, "b", 1, false, scenario.principal, innocent)) + + reasons := scenario.reconciler.RunOnce(testCtx) + require.ErrorIs(t, reasons, business.ErrApprovedBuildAmbiguous) + + _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, + "the ambiguous principal is refused") + + digest, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, innocent) + require.NoError(t, err, + "a principal whose documents say exactly one thing must not be refused because an unrelated one is ambiguous") + require.Equal(t, business.ApprovedDigest(scenario.build), digest) + require.Equal(t, uint64(1), incarnation) +} + +// TestAnAuthorityOutsideTheCeilingApprovesNothing is the check that an authentic +// signature is not an approval. +// +// A signed document says who wrote it. Whether what it claims is inside the +// ceiling a platform administrator wrote is a separate question, which core +// keeps as a separate call — and the clause that matters for this view is that +// the build must be one THE ENVELOPE approved. Without it a delivery writer +// approves any image by signing a document naming it, which would defeat the +// whole execution check this view feeds. +func TestAnAuthorityOutsideTheCeilingApprovesNothing(t *testing.T) { + scenario := newApprovedBuildScenario(t, "abc") + + // A build the envelope does not approve. Core's second fixture build is + // well-formed and not this scenario's, so the refusal is about the ceiling + // rather than about a malformed digest. + unapproved := solutionhost.FixtureEnvelope().ApprovedBuilds[1] + require.NotEqual(t, scenario.build, unapproved) + + document := scenario.authority(t, "a", 1, false, scenario.principal) + document.ApprovedBuild = unapproved + require.NoError(t, document.Validate(), + "the document must be VALID and merely outside the ceiling, or this tests document validation instead") + scenario.deliverAuthority(t, document) + + reasons := scenario.reconciler.RunOnce(testCtx) + require.Error(t, reasons) + require.Contains(t, reasons.Error(), "outside this host's ceiling") + + _, _, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.ErrorIs(t, err, business.ErrUnknownExecutionPrincipal, + "a document the ceiling does not cover must approve nothing, however well signed") +} + +// TestAnUnreadableInboxLeavesThePreviousViewServing is the one failure that IS +// whole-pass, and the distinction is the point. +// +// A row this host can no longer verify leaves the approved state for EVERY +// principal unestablished, because which principals a row grants to is only +// knowable by reading it — and the one row whose disappearance grants something +// is a tombstone. So nothing is installed and the last good view keeps serving, +// which is a different answer from "no principal is approved". +func TestAnUnreadableInboxLeavesThePreviousViewServing(t *testing.T) { + scenario := newApprovedBuildScenario(t, "abu") + + scenario.deliverAuthority(t, scenario.authority(t, "a", 2, false, scenario.principal)) + scenario.runPass(t) + _, incarnation, err := scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.NoError(t, err) + require.Equal(t, uint64(2), incarnation) + + // A second reconciler over the SAME inbox whose verifier refuses every + // carrier: the host's signer policy moved, which is exactly the case + // re-verification on every pass exists for. + refusing, err := business.NewApprovedBuildReconciler( + activationForVerifier(t, scenario.delivery.service, productionPathCoordinate, + scenario.envelope, refusingBundleVerifier{})) + require.NoError(t, err) + + // It has its own empty view, so prove it refuses the read FIRST — then the + // assertion below is about the pass rather than about a fresh instance. + require.ErrorIs(t, refusing.RunOnce(testCtx), business.ErrApprovedBuildInboxUnreadable) + + // The ORIGINAL reconciler is untouched: a pass that could not read the + // inbox installs nothing, so its view still serves what it last + // established. + _, incarnation, err = scenario.reconciler.ApprovedBuild(testCtx, scenario.principal) + require.NoError(t, err) + require.Equal(t, uint64(2), incarnation, + "an unreadable inbox must not retract what a readable one established") +} + +// approvedBuildScenario is one solution's delivery, its envelope, and the +// reconciler reading its inbox. +type approvedBuildScenario struct { + delivery *productionPathDelivery + reconciler *business.ApprovedBuildReconciler + envelope solutionhost.Envelope + binding string + build solutionhost.ImageDigest + solutionID string + namespace string + principal string +} + +func newApprovedBuildScenario(t *testing.T, prefix string, extra ...string) *approvedBuildScenario { + t.Helper() + solutionID := uniqueAlias(prefix) + namespace := "ns-" + solutionID + build := fixturePresenceBuild(t) + + delivery := newProductionPathDelivery(t) + presence := productionPathBinding(t, solutionID, namespace, 1) + delivery.callerNamespace = namespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, + productionPathCarrier(t, presence))) + + scenario := &approvedBuildScenario{ + delivery: delivery, + binding: presence.Binding, + build: build, + solutionID: solutionID, + namespace: namespace, + // Per scenario, so one test's inbox never makes another's principal + // ambiguous. The shared harness grants to one fixed principal for every + // solution, which is precisely the collision the ambiguity check + // refuses — so these tests cannot reuse it. + principal: "principal:operator:" + solutionID, + envelope: solutionhost.Envelope{ + Revision: solutionhost.FixtureEnvelopeRevision, + ApprovedBuilds: []solutionhost.ImageDigest{build}, + }, + } + // EVERY GRANT BEFORE THE ACTIVATION. The reconciler holds the envelope by + // VALUE, so a grant appended afterwards is invisible to it and the document + // naming that principal reads as outside the ceiling. The extra principals + // are therefore constructor arguments rather than something a test adds + // later — the first version of this test added one later and spent a run + // proving that the ceiling check works. + for _, principal := range append([]string{scenario.principal}, extra...) { + scenario.grant(principal) + } + + activation := activationFor(t, delivery.service, productionPathCoordinate, scenario.envelope) + require.NotNil(t, activation) + reconciler, err := business.NewApprovedBuildReconciler(activation) + require.NoError(t, err) + scenario.reconciler = reconciler + return scenario +} + +// grant adds a principal and its own binding to the scenario's ceiling. +// +// Its OWN binding, because core refuses a document in which two principals hold +// one binding ID — "an ID identifies one unit of authority" — so a second +// principal needs a second unit, both in the document and in the envelope that +// has to hold it exactly. +// +// It is unexported and called only from the constructor, because the envelope +// reaches the reconciler by VALUE: a grant appended after the activation is +// built is invisible to it, and the document naming that principal then reads +// as outside the ceiling — a confusing way to fail, and one this test hit +// before the extra principals became constructor arguments. +func (s *approvedBuildScenario) grant(principal string) { + s.envelope.Grants = append(s.envelope.Grants, solutionhost.EnvelopeGrant{ + Principal: principal, + Binding: s.bindingFor(principal), + }) +} + +func (s *approvedBuildScenario) bindingFor(principal string) solutionhost.AuthorityBinding { + binding := authorityBindingFor(s.solutionID, s.namespace) + // One unit of authority per principal, named after it so the two halves + // cannot drift apart. + binding.ID = "binding:" + principal + ":reconcile" + return binding +} + +// runPass runs a reconcile pass and requires that this scenario's own +// principals are not among whatever it refused. +// +// It deliberately does NOT require the pass to be error-free. The inbox is +// host-wide: a pass reads every other test's documents, and other solutions' +// principals are legitimately refused in it. Requiring no error would make +// every test here depend on every other test's deliveries being beyond +// question, which is the contamination that shaped the per-principal design in +// the first place. +func (s *approvedBuildScenario) runPass(t *testing.T) { + t.Helper() + if err := s.reconciler.RunOnce(testCtx); err != nil { + require.NotContains(t, err.Error(), s.principal, + "the pass refused this scenario's own principal, which no assertion here expects") + t.Logf("pass refused principals belonging to other deliveries in the shared inbox: %v", err) + } +} + +func (s *approvedBuildScenario) deliverAuthority(t *testing.T, document *solutionhost.AuthorityDocument) { + t.Helper() + s.delivery.callerNamespace = authorityWriterNamespace + requireDelivered(t, s.delivery.post(t, business.SolutionDeliveryAuthority, + productionPathAuthorityCarrier(t, document))) +} + +// authority builds one authority document over this scenario's binding, naming +// the principals given. +// +// It takes the principals rather than using the shared harness's single fixed +// one, because every assertion here is about which principal a document grants +// to — a builder that always named the same one could not express "two +// documents, one principal" at all. +func (s *approvedBuildScenario) authority( + t *testing.T, suffix string, generation uint64, removed bool, principals ...string, +) *solutionhost.AuthorityDocument { + t.Helper() + document := &solutionhost.AuthorityDocument{ + Schema: solutionhost.SchemaAuthorityV1, + Authority: "acme.test." + s.solutionID + "." + suffix, + Generation: generation, + PresenceBinding: s.binding, + Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, + OwnershipDomain: productionPathDomain, + EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, + } + if removed { + // A withdrawn generation names no principal, approves no build and is + // effective from no generation. + document.Removed = true + } else { + document.ApprovedBuild = s.build + document.EffectiveFrom = 1 + for _, principal := range principals { + document.Principals = append(document.Principals, solutionhost.PrincipalAuthority{ + Principal: principal, + Bindings: []solutionhost.AuthorityBinding{s.bindingFor(principal)}, + }) + } + } + require.NoError(t, document.Validate(), "the fixture must be a valid authority document") + return document +} + +// refusingBundleVerifier attests nothing, which is what a host whose signer +// policy no longer admits a delivery writer looks like to a row already stored. +// +// It refuses at the BUNDLE rather than by returning an unexpected signer +// identity, so the refusal happens in core's verification and not in this +// host's domain policy — the two produce different errors, and the one under +// test is "this row can no longer be verified at all". +type refusingBundleVerifier struct{} + +func (refusingBundleVerifier) VerifyBundle( + context.Context, []byte, json.RawMessage, +) (string, error) { + return "", errors.New("this signer is no longer admitted") +} + +// activationForVerifier is activationFor with the attestation check as a +// parameter, for the one test that needs a reader over the same inbox that can +// no longer verify it. +func activationForVerifier( + t *testing.T, service *business.Service, coordinate string, + envelope solutionhost.Envelope, verifier solutionhost.BundleVerifier, +) *business.SolutionAuthorityActivation { + t.Helper() + reconciler, err := business.NewSolutionHostBindingReconciler(service, + business.SolutionHostBindingReconcilerConfig{ + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: verifier, + Coordinate: coordinate, + Domains: []string{solutionhost.FixtureDomain, productionPathDomain}, + DomainsBySigner: map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}, + Interval: time.Minute, + Envelope: envelope, + }) + require.NoError(t, err) + return reconciler.AuthorityActivation() +} diff --git a/module/services/accounts/code/pkg/infra/current_installation_test.go b/module/services/accounts/code/pkg/infra/current_installation_test.go index 6edb77f08..d68196f5c 100644 --- a/module/services/accounts/code/pkg/infra/current_installation_test.go +++ b/module/services/accounts/code/pkg/infra/current_installation_test.go @@ -24,7 +24,23 @@ func TestModuleCurrentInstallationTenantMembershipAndRevocation(t *testing.T) { require.NoError(t, err) require.Equal(t, installed.Id, out.InstallationId) require.Equal(t, org, out.TenantId) - require.Equal(t, installed.SolutionIdentifier, out.SolutionIdentifier) + // The identities the response carries now: the consented target, and the + // binding whose presence it records. Not the route alias, which a later + // binding may take. + require.Equal(t, installed.TargetId, out.TargetId) + // On the control plane, like the service path: solution_targets is a + // global relation with exact grants, so a bare read is permission-denied + // (SQLSTATE 42501) — the identities the response carries is only readable there. + var consented *business.SolutionTarget + err = testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + var e error + consented, e = testStore.GetSolutionTarget(ctx, installed.TargetId) + return e + }) + require.NoError(t, err) + require.NotNil(t, consented) + require.Equal(t, consented.BindingID, out.BindingId) + require.NotEmpty(t, out.BindingId) _, err = svc.ModuleCurrentInstallation(ctx, caller, org, owner, "example", foreign.Id) require.Error(t, err) _, err = svc.ModuleCurrentInstallation(auth.WithVerifiedDatabaseIdentity(testCtx, foreignOwner, foreignOrg), caller, foreignOrg, foreignOwner, "example", foreign.Id) diff --git a/module/services/accounts/code/pkg/infra/delegated_record_access_test.go b/module/services/accounts/code/pkg/infra/delegated_record_access_test.go index 6dc33dee6..ca5982aad 100644 --- a/module/services/accounts/code/pkg/infra/delegated_record_access_test.go +++ b/module/services/accounts/code/pkg/infra/delegated_record_access_test.go @@ -61,6 +61,7 @@ func TestDelegatedRecordAccessIsolatesCollidingPlacementsAcrossTenants(t *testin const resource, collided = "example.record", "shared-record-id" svc, err := business.NewService(testStore) require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ business.ModulePrincipalID("rows"): {Prefix: "rows", Resources: []string{resource}}, }) diff --git a/module/services/accounts/code/pkg/infra/export_test.go b/module/services/accounts/code/pkg/infra/export_test.go index 7dbb7b166..bf35ce1e9 100644 --- a/module/services/accounts/code/pkg/infra/export_test.go +++ b/module/services/accounts/code/pkg/infra/export_test.go @@ -2,14 +2,25 @@ package infra import ( "context" + "net/http/httptest" + "os" + "path/filepath" "accounts/pkg/infra/internal/txbind" scopedpostgres "github.com/codefly-dev/service-postgres/libs/go" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/verify" ) +// VerifySignedEntityInput is verify.SignedEntity under a local name, exported so +// an external test can embed it when building a signed entity with one field +// changed — which is how the transparency-evidence refusal is exercised without +// hand-assembling a bundle. +type VerifySignedEntityInput = verify.SignedEntity + // IdentityScopeProbe exposes the scope query ListAdministeredOrganizations runs, // so a test can assert what admits a control-plane transaction. Deciding on the // role attribute rather than the assumed role is invisible on a profile that @@ -126,3 +137,133 @@ func OpenScopedBoundaryWithWrites(ctx context.Context, readOnlyConnection, readW func BindControlPlaneTx(ctx context.Context, tx pgx.Tx) context.Context { return txbind.BindControlPlane(ctx, tx) } + +// ExecAsControlPlane runs one statement inside a real control-plane +// transaction, for test states no tenant write path produces directly (an +// expiry that has already passed, a role demoted behind the API's back). +// +// It exists because the obvious shortcut is silently wrong. A test outside this +// package cannot reach the transaction WithControlPlane opens — the binding key +// is internal by design, so that only the code opening a transaction may say +// what authority it carries. A test that pulled the transaction out of the +// context with a plain string key (`ctx.Value("tx").(pgx.Tx)`) worked only for +// as long as that was the binding, and when the binding moved it did not start +// failing on a permission: it read nil and panicked, which is the better of the +// two outcomes. The other, described on BindControlPlaneTx above, is a silent +// fall back to the request pool. +// +// Routing through WithControlPlane keeps the role handling in exactly one +// place, so a test cannot run as the control plane in a way production never +// does. +func (s *PostgresStore) ExecAsControlPlane(ctx context.Context, sql string, args ...any) error { + return s.WithControlPlane(ctx, func(ctx context.Context) error { + _, err := s.getQueryExecutor(ctx).Exec(ctx, sql, args...) + return err + }) +} + +// ScanAsControlPlane reads one row as the control plane, for a test asserting on +// a column no store method returns. +// +// It goes through WithControlPlane like its Exec counterpart rather than fishing +// the transaction out of the context: the context key is internal to pkg/infra +// by design, so a plain-string lookup is not refused — it reads nil, and the +// query silently falls back to the request pool where it would fail on a +// permission it should never have lacked. +func (s *PostgresStore) ScanAsControlPlane( + ctx context.Context, sql string, into []any, args ...any, +) error { + return s.WithControlPlane(ctx, func(ctx context.Context) error { + return s.getQueryExecutor(ctx).QueryRow(ctx, sql, args...).Scan(into...) + }) +} + +// VerifySignedEntity exposes the keyless verifier's DECISION, so a test can +// drive it with an in-process Sigstore instead of a hand-assembled bundle +// document. +// +// Exported for tests deliberately rather than testing through VerifyBundle's +// JSON: the thing worth proving is that the policy accepts and refuses the right +// identities against a real trust root, and a bundle written by hand proves only +// that the decoder works. The path under test is the one production takes — the +// JSON entry point decodes and calls straight into this. +func VerifySignedEntity( + verifier any, entity VerifySignedEntityInput, payload []byte, +) (string, error) { + return verifier.(*keylessBundleVerifier).verifySignedEntity(entity, payload) +} + +// NewKeylessVerifierWithoutPolicyValidation builds the keyless verifier over +// trust material and a policy WITHOUT running the policy's boot validation. +// +// It exists for one reason, and the reason is a limitation worth stating rather +// than hiding. Production policy hygiene requires every signer to name a source +// repository or a build config, because a workflow identity alone matches that +// same workflow path in every fork of a repository. The in-process Sigstore used +// in tests mints leaf certificates carrying ONLY the OIDC issuer extension — it +// has no way to emit `SourceRepositoryURI` — so no certificate it can produce +// satisfies a policy that production would accept. +// +// So the accept path is exercised with a policy shape production REFUSES, and +// the refusal of that shape is asserted separately by the Validate tests. What +// is genuinely not covered end to end is the extension matching itself: that +// `SourceRepositoryURI` and `SourceRepositoryRef` are compared at all is +// delegated to sigstore-go and exercised only by its own suite. +func NewKeylessVerifierWithoutPolicyValidation( + trustedMaterial trustedMaterialForTest, policy *SolutionHostVerificationPolicy, +) (any, error) { + verifier, err := verify.NewVerifier(trustedMaterial, + verify.WithTransparencyLog(1), + verify.WithObserverTimestamps(1), + ) + if err != nil { + return nil, err + } + return &keylessBundleVerifier{verifier: verifier, policy: policy}, nil +} + +// trustedMaterialForTest is root.TrustedMaterial under a local name. +type trustedMaterialForTest = root.TrustedMaterial + +// KeylessSignerDomains reads the signer-to-domain mapping off a verifier built +// by either constructor, so a test can assert the mapping travels with the +// policy it came from. +func KeylessSignerDomains(verifier any) map[string][]string { + return verifier.(*keylessBundleVerifier).SignerDomains() +} + +// NewKubernetesClientForTest points the client at a test API server, using a +// throwaway token file. +// +// Exported for tests so the TokenReview path is exercised over real HTTP against +// a real handler: a fake CLIENT would let the AUDIENCE go unsent and untested +// while looking identical, and the audience is the thing that stops a token +// minted for another service authenticating here. +func NewKubernetesClientForTest(t testingT, server *httptest.Server) *KubernetesClient { + t.Helper() + directory := t.TempDir() + path := filepath.Join(directory, "token") + if err := os.WriteFile(path, []byte("host-own-token"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + return NewKubernetesClientForTestWithToken(t, server, path) +} + +// NewKubernetesClientForTestWithToken is the same with a caller-owned token +// file, so a test can rotate it mid-run. +func NewKubernetesClientForTestWithToken(t testingT, server *httptest.Server, tokenPath string) *KubernetesClient { + t.Helper() + return &KubernetesClient{ + endpoint: server.URL, + tokenPath: tokenPath, + http: server.Client(), + } +} + +// testingT is the sliver of *testing.T these helpers need, so export_test.go +// does not import testing into the production package's namespace. +type testingT interface { + Helper() + TempDir() string + Fatalf(string, ...any) +} diff --git a/module/services/accounts/code/pkg/infra/kubernetes_execution_reviewer.go b/module/services/accounts/code/pkg/infra/kubernetes_execution_reviewer.go new file mode 100644 index 000000000..795a19905 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/kubernetes_execution_reviewer.go @@ -0,0 +1,51 @@ +package infra + +import ( + "context" + + "accounts/pkg/business" +) + +// KubernetesClient as the execution reviewer the business layer asks. +// +// A thin adapter rather than business depending on this package's types: the +// business layer owns what an execution decision needs, and the shape of +// Kubernetes' API is this package's problem. Written out so the mapping between +// the two is visible in one place — in particular that the SPEC's image and the +// STATUS's imageID stay separate all the way across the boundary, because +// collapsing them here would defeat the three-type separation no matter how +// carefully the business layer compares. + +// ReviewToken authenticates a projected service-account token. +func (c *KubernetesClient) ReviewToken( + ctx context.Context, token, audience string, +) (*business.ReviewedExecutionToken, error) { + reviewed, err := c.reviewToken(ctx, token, audience) + if err != nil { + return nil, err + } + return &business.ReviewedExecutionToken{ + ServiceAccount: reviewed.ServiceAccount, + Namespace: reviewed.Namespace, + PodName: reviewed.PodName, + PodUID: reviewed.PodUID, + }, nil +} + +// RunningContainer reads what a container asked to run and what it runs. +func (c *KubernetesClient) RunningContainer( + ctx context.Context, namespace, pod, container string, +) (*business.RunningContainerStatus, error) { + image, err := c.runningContainer(ctx, namespace, pod, container) + if err != nil { + return nil, err + } + return &business.RunningContainerStatus{ + UID: image.UID, + ImageID: image.ImageID, + DeclaredImage: image.DeclaredImage, + Found: image.Found, + }, nil +} + +var _ business.ExecutionReviewer = (*KubernetesClient)(nil) diff --git a/module/services/accounts/code/pkg/infra/kubernetes_token_review.go b/module/services/accounts/code/pkg/infra/kubernetes_token_review.go new file mode 100644 index 000000000..924655712 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/kubernetes_token_review.go @@ -0,0 +1,421 @@ +package infra + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +// The Kubernetes API the host needs, and nothing more. +// +// This is a direct REST client rather than client-go, deliberately. The host +// needs exactly two calls — review a token, read one pod by name — and client-go +// would bring a dependency tree larger than this service's own in order to +// provide them, along with informers, caches and a watch machinery that a +// verifier must not have. What it would buy is convenience over an API that is +// stable and well-specified; what it would cost is an auditable surface. Two +// request shapes are readable in full; a cache is not. +// +// Everything here is read-only against the API server. There is no code path +// that writes a Kubernetes object. + +// ErrKubernetesUnavailable reports that the API server could not be asked. +// +// It is distinct from a refusal on purpose, and the distinction decides a +// response code: a token the API server REFUSED is a terminal 401 (the caller's +// credential is wrong and retrying asks the same question), while an API server +// that could not be REACHED is a retryable 503. Collapsing them would make a +// control-plane outage look like a fleet of misconfigured callers. +var ErrKubernetesUnavailable = errors.New("kubernetes api is unavailable") + +// ErrTokenRejected reports that the API server reviewed the token and said no. +var ErrTokenRejected = errors.New("kubernetes rejected the presented token") + +// The in-cluster service account paths. Fixed by Kubernetes, not configurable: +// a configurable path is a way to point the host's own identity somewhere else. +const ( + inClusterTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + inClusterCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + inClusterNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" +) + +// KubernetesClient is the host's read-only window onto the API server. +type KubernetesClient struct { + endpoint string + http *http.Client + // token is this SERVICE'S own credential, re-read per request. + // + // Re-read rather than cached: a projected service-account token is rotated + // by the kubelet at ~80% of its lifetime, and a value captured at boot stops + // working some hours later — which presents as the API server refusing the + // host's own identity, the single most confusing failure available here. + tokenPath string +} + +// NewKubernetesClient builds the client from the in-cluster environment. +// +// It refuses when the environment is not a cluster, rather than constructing a +// client that will fail on first use. A host configured to verify executions +// that cannot reach the API server must say so at boot, for the same reason the +// keyless trust policy refuses at boot: "I cannot perform this check" and "this +// caller failed the check" must not be the same observable. +func NewKubernetesClient() (*KubernetesClient, error) { + host := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST")) + port := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT")) + if host == "" || port == "" { + return nil, fmt.Errorf("%w: KUBERNETES_SERVICE_HOST/PORT are unset, so this is not an in-cluster environment", + ErrKubernetesUnavailable) + } + authority, err := os.ReadFile(inClusterCAPath) + if err != nil { + return nil, fmt.Errorf("%w: cannot read the cluster CA at %s: %w", ErrKubernetesUnavailable, inClusterCAPath, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(authority) { + return nil, fmt.Errorf("%w: the cluster CA at %s contains no usable certificate", ErrKubernetesUnavailable, inClusterCAPath) + } + if _, err := os.ReadFile(inClusterTokenPath); err != nil { + return nil, fmt.Errorf("%w: cannot read this service's own token at %s: %w", + ErrKubernetesUnavailable, inClusterTokenPath, err) + } + return &KubernetesClient{ + endpoint: "https://" + host + ":" + port, + tokenPath: inClusterTokenPath, + http: &http.Client{ + // A bounded timeout, because every caller of this is on a request + // path that must fail rather than hang. The delivered namespaces are + // default-deny egress, so a misconfigured NetworkPolicy makes this + // call hang rather than refuse — and without a deadline that becomes + // a request the client gave up on while the host still holds its + // resources. + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS13}, + }, + }, + }, nil +} + +// InClusterNamespace is the namespace this service runs in. +func InClusterNamespace() (string, error) { + namespace, err := os.ReadFile(inClusterNamespacePath) + if err != nil { + return "", fmt.Errorf("%w: cannot read this service's namespace: %w", ErrKubernetesUnavailable, err) + } + return strings.TrimSpace(string(namespace)), nil +} + +// ReviewedToken is what a TokenReview established about a caller. +type ReviewedToken struct { + // ServiceAccount and Namespace are parsed out of the authenticated + // username, which Kubernetes renders as + // `system:serviceaccount::`. + ServiceAccount string + Namespace string + // PodName and PodUID come from the authentication extra claims a BOUND + // token carries. Empty for a legacy non-bound token, which is a meaningful + // absence rather than a blank: a credential that names no pod cannot be + // checked against a pod, so an execution-bound decision must refuse it + // rather than skip the check. + PodName string + PodUID string +} + +// tokenReviewRequest / tokenReviewResponse are the minimum of Kubernetes' +// TokenReview that the host uses. Written out rather than imported so the wire +// shape this depends on is visible in one place. +type tokenReviewRequest struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Spec tokenReviewRequestSpec `json:"spec"` +} + +type tokenReviewRequestSpec struct { + Token string `json:"token"` + Audiences []string `json:"audiences,omitempty"` +} + +type tokenReviewResponse struct { + Status struct { + Authenticated bool `json:"authenticated"` + Audiences []string `json:"audiences"` + Error string `json:"error"` + User struct { + Username string `json:"username"` + Extra map[string][]string `json:"extra"` + } `json:"user"` + } `json:"status"` +} + +// Extra claims a bound service-account token carries, naming the pod it was +// projected into. +const ( + podNameClaim = "authentication.kubernetes.io/pod-name" + podUIDClaim = "authentication.kubernetes.io/pod-uid" +) + +// ReviewToken asks the API server whether a token is valid FOR THIS AUDIENCE, +// and what it authenticates as. +// +// The audience is required and is checked by the API server, not here. A +// TokenReview without one validates the token for any audience, which would let +// a token minted for another service — a token its holder was legitimately +// given, for a different purpose — authenticate here. Passing the audience is +// what makes this an authentication of a caller rather than of a bearer. +func (c *KubernetesClient) reviewToken(ctx context.Context, token, audience string) (*ReviewedToken, error) { + if strings.TrimSpace(token) == "" { + return nil, fmt.Errorf("%w: no token was presented", ErrTokenRejected) + } + if strings.TrimSpace(audience) == "" { + // A programming error rather than a caller's: refuse loudly instead of + // issuing an audience-less review that would accept too much. + return nil, errors.New("a token review requires an audience; an audience-less review accepts a token minted for any service") + } + body, err := json.Marshal(tokenReviewRequest{ + APIVersion: "authentication.k8s.io/v1", + Kind: "TokenReview", + Spec: tokenReviewRequestSpec{Token: token, Audiences: []string{audience}}, + }) + if err != nil { + return nil, err + } + + var reviewed tokenReviewResponse + if err := c.post(ctx, "/apis/authentication.k8s.io/v1/tokenreviews", body, &reviewed); err != nil { + return nil, err + } + if !reviewed.Status.Authenticated { + reason := reviewed.Status.Error + if reason == "" { + reason = "the api server did not say why" + } + return nil, fmt.Errorf("%w: %s", ErrTokenRejected, reason) + } + // The API server echoes the audiences the token is valid for. Requiring our + // audience to be among them is belt and braces over the request above — but + // it is cheap, and it is the check that would catch an API server version + // that ignored the request's audiences rather than refusing. + if !containsString(reviewed.Status.Audiences, audience) { + return nil, fmt.Errorf("%w: the token is not valid for audience %q", ErrTokenRejected, audience) + } + + account, namespace, err := parseServiceAccountUsername(reviewed.Status.User.Username) + if err != nil { + return nil, err + } + return &ReviewedToken{ + ServiceAccount: account, + Namespace: namespace, + PodName: firstClaim(reviewed.Status.User.Extra, podNameClaim), + PodUID: firstClaim(reviewed.Status.User.Extra, podUIDClaim), + }, nil +} + +// RunningContainerImage is what one container of one pod is actually running. +type RunningContainerImage struct { + // UID is the pod's metadata.uid, for comparison against the UID the token + // named. A pod NAME is reused across generations of a workload; the UID is + // not, which is why the comparison is on the UID. + UID string + // ImageID is the running image's digest-bearing reference from the + // container's STATUS — what it is running — never the spec's image, which + // is what it was asked to run. + ImageID string + // DeclaredImage is the SPEC's image: what the pod asked to run. Reported, + // never compared against an approved digest. Whoever can create the pod + // writes it, so it is evidence of intent and never of fact — and a pod spec + // naming an approved image while running something else is exactly what a + // moved tag produces. + DeclaredImage string + // Found reports whether the named container exists in the pod's statuses at + // all. A missing container is distinct from a container running the wrong + // thing, and an execution-bound decision must refuse both rather than treat + // an absence as a non-match it can explain. + Found bool +} + +type podResponse struct { + Metadata struct { + UID string `json:"uid"` + } `json:"metadata"` + // Spec carries what the pod ASKED to run. Read so the declared image is a + // real value this host holds rather than a type with no referent — and so a + // test can prove the approved digest is compared against the RUNNING image + // and never against this one. + Spec struct { + Containers []struct { + Name string `json:"name"` + Image string `json:"image"` + } `json:"containers"` + InitContainers []struct { + Name string `json:"name"` + Image string `json:"image"` + } `json:"initContainers"` + } `json:"spec"` + Status struct { + ContainerStatuses []struct { + Name string `json:"name"` + ImageID string `json:"imageID"` + } `json:"containerStatuses"` + InitContainerStatuses []struct { + Name string `json:"name"` + ImageID string `json:"imageID"` + } `json:"initContainerStatuses"` + } `json:"status"` +} + +// RunningContainer reads one named container's running image from one pod. +// +// Only `get` is used, never `list`: the host asks about a pod it was told about +// by a TokenReview, so it never needs to enumerate. That keeps the RBAC the +// deployment must grant to `get` on pods in the cell's declared namespaces — +// which, stated accurately, reaches any pod in those namespaces by name, because +// `resourceNames` cannot be used when replica pod names are generated. No +// enumeration, and only within those namespaces. +// +// Init container statuses are searched too, so that a workload naming an init +// container as its authenticating one is answered rather than silently reported +// as absent. Whether an init container MAY authenticate is a policy question the +// caller decides from the document's `non_authenticating` list; this is a read. +func (c *KubernetesClient) runningContainer( + ctx context.Context, namespace, pod, container string, +) (*RunningContainerImage, error) { + if namespace == "" || pod == "" || container == "" { + return nil, fmt.Errorf("%w: a pod read needs a namespace, a pod and a container name", ErrKubernetesUnavailable) + } + var body podResponse + if err := c.get(ctx, "/api/v1/namespaces/"+namespace+"/pods/"+pod, &body); err != nil { + return nil, err + } + image := &RunningContainerImage{UID: body.Metadata.UID} + // The spec's image is read FIRST and kept separately. It is never compared + // against the approved digest — it is reported so a mismatch between what a + // pod asked for and what it runs is visible in a refusal, which is the + // signature of a moved tag. + for _, declared := range body.Spec.Containers { + if declared.Name == container { + image.DeclaredImage = declared.Image + break + } + } + if image.DeclaredImage == "" { + for _, declared := range body.Spec.InitContainers { + if declared.Name == container { + image.DeclaredImage = declared.Image + break + } + } + } + for _, status := range body.Status.ContainerStatuses { + if status.Name == container { + image.ImageID, image.Found = status.ImageID, true + return image, nil + } + } + for _, status := range body.Status.InitContainerStatuses { + if status.Name == container { + image.ImageID, image.Found = status.ImageID, true + return image, nil + } + } + return image, nil +} + +func (c *KubernetesClient) post(ctx context.Context, path string, body []byte, into any) error { + return c.do(ctx, http.MethodPost, path, body, into) +} + +func (c *KubernetesClient) get(ctx context.Context, path string, into any) error { + return c.do(ctx, http.MethodGet, path, nil, into) +} + +// do performs one API call, and maps every failure that is not a decision onto +// ErrKubernetesUnavailable. +// +// That mapping is the important part. A transport failure, a 5xx, a 403 on the +// host's own RBAC and an unparseable body are all "I could not ask" — they say +// nothing about the caller being verified, and a decision inferred from any of +// them would be a verification the host did not perform. The host's own +// credential being refused is especially worth keeping on this side: it is the +// deployment's fault, and reporting it as the caller's would send an operator to +// the wrong service. +func (c *KubernetesClient) do(ctx context.Context, method, path string, body []byte, into any) error { + // Re-read per request: a projected token is rotated at ~80% of its + // lifetime, so a value captured at boot stops working some hours later. + token, err := os.ReadFile(c.tokenPath) + if err != nil { + return fmt.Errorf("%w: cannot read this service's own token: %w", ErrKubernetesUnavailable, err) + } + var reader *bytes.Reader + if body != nil { + reader = bytes.NewReader(body) + } else { + reader = bytes.NewReader(nil) + } + request, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, reader) + if err != nil { + return fmt.Errorf("%w: %w", ErrKubernetesUnavailable, err) + } + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token))) + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + response, err := c.http.Do(request) + if err != nil { + return fmt.Errorf("%w: %s %s: %w", ErrKubernetesUnavailable, method, path, err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("%w: %s %s answered %d", ErrKubernetesUnavailable, method, path, response.StatusCode) + } + if err := json.NewDecoder(response.Body).Decode(into); err != nil { + return fmt.Errorf("%w: %s %s returned an unreadable body: %w", ErrKubernetesUnavailable, method, path, err) + } + return nil +} + +// parseServiceAccountUsername splits `system:serviceaccount::`. +// +// A username in any other shape is refused rather than accepted with empty +// parts. Kubernetes authenticates humans and other identities too, and a token +// belonging to one of those must not read as a service account in the "" +// namespace — which would then be compared against a configured namespace and, +// for the presence half where the namespace comes from the document, could +// compare equal to nothing by accident. +func parseServiceAccountUsername(username string) (account, namespace string, err error) { + const prefix = "system:serviceaccount:" + if !strings.HasPrefix(username, prefix) { + return "", "", fmt.Errorf("%w: %q is not a service account identity", ErrTokenRejected, username) + } + namespace, account, found := strings.Cut(strings.TrimPrefix(username, prefix), ":") + if !found || namespace == "" || account == "" { + return "", "", fmt.Errorf("%w: %q is not a well-formed service account identity", ErrTokenRejected, username) + } + return account, namespace, nil +} + +func firstClaim(extra map[string][]string, key string) string { + if values := extra[key]; len(values) > 0 { + return values[0] + } + return "" +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/module/services/accounts/code/pkg/infra/module_authority_fake_test.go b/module/services/accounts/code/pkg/infra/module_authority_fake_test.go new file mode 100644 index 000000000..eb7409f33 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/module_authority_fake_test.go @@ -0,0 +1,29 @@ +package infra_test + +import ( + "context" + + "accounts/pkg/business" +) + +// currentModuleAuthority is a live-authority read reporting a CURRENT +// installation, wired through the real seam like this package's other fakes. +// +// Enforcement at use made every capability test in this package fail closed: +// a Service that declares module principals but cannot re-read live authority +// now refuses every capability rather than deciding on the declared ceiling +// alone. That is the enforcement working, so the tests wire the read rather +// than the Service growing a permissive default — a default would make the +// check optional in exactly the configuration tests run in, which is the one +// configuration where it must not be. +// +// Revision and epoch are 0 on purpose: a credential sealed at any non-zero +// value is still refused through this, so a test proving a stale seal is +// rejected needs no opt-out. +type currentModuleAuthority struct{} + +func (currentModuleAuthority) LiveModuleAuthority( + _ context.Context, _, _, _ string, +) (*business.LiveModuleAuthority, error) { + return &business.LiveModuleAuthority{InstallationID: "installation-current"}, nil +} diff --git a/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go b/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go index 4c2a992f6..ab02a7739 100644 --- a/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_audit_atomicity_test.go @@ -29,7 +29,9 @@ func auditedService(t *testing.T, emitter business.AuditEmitter) *business.Servi service, err := business.NewService(testStore) require.NoError(t, err) service.SetAuditEmitter(emitter) - return service + // These tests revoke principals and remove members, which are witnessed + // narrowings; see wireNarrowingPolicyLog. + return wireNarrowingPolicyLog(service) } func countRows(t *testing.T, query string, args ...any) int { diff --git a/module/services/accounts/code/pkg/infra/postgres_catalogue_installations_test.go b/module/services/accounts/code/pkg/infra/postgres_catalogue_installations_test.go index 6b4b94caa..b85f3d3c4 100644 --- a/module/services/accounts/code/pkg/infra/postgres_catalogue_installations_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_catalogue_installations_test.go @@ -75,7 +75,12 @@ func TestListCatalogueInstallationsReadsTeamsReachingTheRoot(t *testing.T) { record := catalogueInstallations(t)[installation.Id] require.NotNil(t, record, "an active installation is listed") require.Equal(t, orgID, record.Installation.GetOrgId()) - require.Equal(t, "acme.example/solution", record.Installation.GetSolutionIdentifier()) + // An installation names the immutable TARGET; `solution_identifier` went + // with the runtime registration writer, and the declared record is what + // maps a target back to its solution. + require.Equal(t, installation.GetTargetId(), record.Installation.GetTargetId()) + require.NotEmpty(t, record.Installation.GetTargetId(), + "a listed installation must name the target it was installed against") require.Equal(t, "Test Org", record.OrgName) require.Equal(t, "acme.example/solution:1.0.0", record.AgentIdentifier) diff --git a/module/services/accounts/code/pkg/infra/postgres_executable_artifacts_test.go b/module/services/accounts/code/pkg/infra/postgres_executable_artifacts_test.go index 5c0e1eebf..c34b41791 100644 --- a/module/services/accounts/code/pkg/infra/postgres_executable_artifacts_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_executable_artifacts_test.go @@ -18,7 +18,23 @@ import ( func TestExecutableArtifactPostgresConsentRevocationAndTenantFloor(t *testing.T) { org, owner, role, installation, _ := installFixture(t, "definitions", "configure") - policy := business.ExecutableArtifactPolicy{Schema: "example.artifact/v1", Sources: map[string]string{"acme.example.solution": installation.SolutionIdentifier}, Activate: business.ArtifactPermission{Resource: "definitions", Action: "configure"}, Run: business.ArtifactPermission{Resource: "definitions", Action: "configure"}, Contracts: []business.ArtifactContract{{Kind: "model", Name: "example/profile", Digest: "sha256:" + strings.Repeat("a", 64)}}, RequiredKinds: []string{"model"}} + // Sources names the BINDING the consented target records, not the route + // alias: an alias is reusable, so keying on one would let a replacement + // binding inherit this installation's approvals. Read it off the target the + // installation names rather than restating it, so the fixture and the policy + // cannot drift apart. + // On the control plane, like the service path: solution_targets is a + // global relation with exact grants, so a bare read is permission-denied + // (SQLSTATE 42501) — the binding the policy installs is only readable there. + var consented *business.SolutionTarget + err := testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + var e error + consented, e = testStore.GetSolutionTarget(ctx, installation.TargetId) + return e + }) + require.NoError(t, err) + require.NotNil(t, consented, "the fixture's installation must name a live target") + policy := business.ExecutableArtifactPolicy{Schema: "example.artifact/v1", Sources: map[string]string{"acme.example.solution": consented.BindingID}, Activate: business.ArtifactPermission{Resource: "definitions", Action: "configure"}, Run: business.ArtifactPermission{Resource: "definitions", Action: "configure"}, Contracts: []business.ArtifactContract{{Kind: "model", Name: "example/profile", Digest: "sha256:" + strings.Repeat("a", 64)}}, RequiredKinds: []string{"model"}} caller := business.ModuleCaller{PrincipalID: business.ModulePrincipalID("example"), BoundOrg: org} svc, err := business.NewService(testStore) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/infra/postgres_installations.go b/module/services/accounts/code/pkg/infra/postgres_installations.go index b5f370e4f..f90fc1d5a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_installations.go +++ b/module/services/accounts/code/pkg/infra/postgres_installations.go @@ -12,37 +12,47 @@ import ( gen "accounts/pkg/gen/saas/accounts/v1" "github.com/codefly-dev/core/wool" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "google.golang.org/protobuf/types/known/timestamppb" ) // installationColumns is the canonical SELECT list, uuid columns cast to text so // they scan into strings (matching the gen.Installation wire shape). +// target_id and revoked_reason are nullable — a revoked historical row whose +// target could not be established at the cutover carries neither — so both are +// coalesced here rather than scanned into pointers. An empty target_id reaching +// a reader is therefore the one documented case and not a scan surprise; the +// `installations_active_requires_target` CHECK is what makes it impossible on an +// active row. const installationColumns = `id::text, org_id::text, agent_principal_id::text, - solution_identifier, owner_principal_id::text, co_owner_principal_ids::text[], - root_scope_node_id::text, status, created_at, revoked_at` + owner_principal_id::text, co_owner_principal_ids::text[], + root_scope_node_id::text, status, created_at, revoked_at, + COALESCE(target_id::text, ''), COALESCE(revoked_reason, '')` func scanInstallation(row rowScanner) (*gen.Installation, error) { var ( - id, orgID, agentID, solution, ownerID, rootNodeID, status string - coOwners []string - createdAt time.Time - revokedAt *time.Time + id, orgID, agentID, ownerID, rootNodeID, status string + targetID, revokedReason string + coOwners []string + createdAt time.Time + revokedAt *time.Time ) - if err := row.Scan(&id, &orgID, &agentID, &solution, &ownerID, &coOwners, - &rootNodeID, &status, &createdAt, &revokedAt); err != nil { + if err := row.Scan(&id, &orgID, &agentID, &ownerID, &coOwners, + &rootNodeID, &status, &createdAt, &revokedAt, &targetID, &revokedReason); err != nil { return nil, err } out := &gen.Installation{ Id: id, OrgId: orgID, AgentPrincipalId: agentID, - SolutionIdentifier: solution, OwnerPrincipalId: ownerID, CoOwnerPrincipalIds: coOwners, RootScopeNodeId: rootNodeID, Status: installationStatusFromColumn(status), CreatedAt: timestamppb.New(createdAt), + TargetId: targetID, + RevokedReason: revokedReason, } if revokedAt != nil { out.RevokedAt = timestamppb.New(*revokedAt) @@ -96,9 +106,9 @@ func firstEligibleOwner(ctx context.Context, executor QueryExecutor, orgID strin func (s *PostgresStore) InstallSolution(ctx context.Context, params *business.InstallSolutionParams) (*gen.Installation, error) { w := wool.Get(ctx).In("InstallSolution", wool.Field("org_id", params.OrgID), - wool.Field("solution", params.SolutionIdentifier)) + wool.Field("target_id", params.TargetID)) executor := s.getQueryExecutor(ctx) - if err := lockSolutionInstallation(ctx, executor, params.OrgID, params.SolutionIdentifier); err != nil { + if err := lockSolutionInstallation(ctx, executor, params.OrgID, params.TargetID); err != nil { return nil, err } @@ -122,8 +132,8 @@ func (s *PostgresStore) InstallSolution(ctx context.Context, params *business.In // than silently ignored — a changed ceiling is applied by uninstall + reinstall. existing, err := scanInstallation(executor.QueryRow(ctx, `SELECT `+installationColumns+` FROM installations - WHERE org_id = $1 AND solution_identifier = $2 AND status = 'active'`, - params.OrgID, params.SolutionIdentifier)) + WHERE org_id = $1 AND target_id = $2::uuid AND status = 'active'`, + params.OrgID, params.TargetID)) if err == nil { if e := reconcileExistingInstallation(ctx, executor, existing, params); e != nil { return nil, e @@ -166,11 +176,11 @@ func (s *PostgresStore) InstallSolution(ctx context.Context, params *business.In // 4. The installation row with its owner of record. installation, err := scanInstallation(executor.QueryRow(ctx, ` INSERT INTO installations - (id, org_id, agent_principal_id, solution_identifier, owner_principal_id, + (id, org_id, agent_principal_id, target_id, owner_principal_id, co_owner_principal_ids, root_scope_node_id, status, installer_principal_id) - VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, 'active', NULLIF($7, '')::uuid) + VALUES (gen_random_uuid(), $1, $2, $3::uuid, $4, $5, $6, 'active', NULLIF($7, '')::uuid) RETURNING `+installationColumns, - params.OrgID, agentID, params.SolutionIdentifier, params.OwnerPrincipalID, + params.OrgID, agentID, params.TargetID, params.OwnerPrincipalID, stringArray(params.CoOwnerPrincipalIDs), nodeID, params.InstallerPrincipalID, )) if err != nil { @@ -180,7 +190,7 @@ func (s *PostgresStore) InstallSolution(ctx context.Context, params *business.In // (the caller's retry hits the idempotent path) rather than an opaque 500. if isUniqueViolation(err) { return nil, business.NewStoreError( - fmt.Errorf("solution %s is already being installed in org %s", params.SolutionIdentifier, params.OrgID), + fmt.Errorf("solution target %s is already being installed in org %s", params.TargetID, params.OrgID), business.ErrTypeConflict, ) } @@ -239,11 +249,11 @@ func (s *PostgresStore) getOrRegisterSolutionNode(ctx context.Context, params *b SELECT n.id::text, n.scope_path::text FROM installations i JOIN scope_nodes n ON n.id = i.root_scope_node_id - WHERE i.org_id = $1 AND i.solution_identifier = $2 AND i.status = 'revoked' + WHERE i.org_id = $1 AND i.target_id = $2::uuid AND i.status = 'revoked' AND n.kind = 'solution' ORDER BY i.revoked_at DESC LIMIT 1`, - params.OrgID, params.SolutionIdentifier).Scan(&nodeID, &nodePath) + params.OrgID, params.TargetID).Scan(&nodeID, &nodePath) if err == nil { return nodeID, nodePath, nil } @@ -252,7 +262,7 @@ func (s *PostgresStore) getOrRegisterSolutionNode(ctx context.Context, params *b } label := params.RootScopeLabel if label == "" { - label = params.SolutionIdentifier + label = params.RouteAlias } node := &gen.ScopeNode{ Id: business.NewIDString(), @@ -304,8 +314,8 @@ func reconcileExistingInstallation(ctx context.Context, executor QueryExecutor, return nil } return business.NewStoreError( - fmt.Errorf("solution %s is already installed with different immutable attributes, owner, ceiling, role, or lifecycle; explicit lifecycle action is required", - params.SolutionIdentifier), + fmt.Errorf("solution target %s is already installed with different immutable attributes, owner, ceiling, role, or lifecycle; explicit lifecycle action is required", + params.TargetID), business.ErrTypeConflict) } @@ -323,6 +333,175 @@ func sameStringSet(a, b []string) bool { return slices.Equal(sortedA, sortedB) } +// installationStatusColumn is the inverse of installationStatusFromColumn: the +// column value an enum selects, or "" for UNSPECIFIED, which selects every row. +func installationStatusColumn(status gen.InstallationStatus) string { + switch status { + case gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE: + return "active" + case gen.InstallationStatus_INSTALLATION_STATUS_REVOKED: + return "revoked" + default: + return "" + } +} + +// ListInstallations returns one page of an org's installations with health +// resolved live for each, keyset-paginated on id. +// +// id is the keyset rather than target_id or created_at because it is the +// only column that is unique across the whole table: (org_id, target_id) +// is unique only among ACTIVE rows, so a revoked install of the same solution +// would make that cursor skip or repeat a row, and created_at is not unique at +// all. The cursor is therefore stable and total whatever the status filter is. +// +// RLS confines every read to the caller's tenant; the explicit org_id predicate is +// a second gate on top of that floor, in the same spirit as the scope listing. +func (s *PostgresStore) ListInstallations( + ctx context.Context, orgID string, status gen.InstallationStatus, pageToken string, limit int, +) ([]*gen.InstallationSummary, error) { + w := wool.Get(ctx).In("ListInstallations", wool.Field("org_id", orgID)) + executor := s.getQueryExecutor(ctx) + + // A page token is a uuid this store itself emitted. Canonicalize it rather + // than binding it raw: comparing on id::text means an unparseable cursor + // simply matches nothing instead of aborting the transaction on a uuid cast + // (the same trap the scope point check hit), and a uuid has several accepted + // spellings but one text form. + cursor := "" + if pageToken != "" { + parsed, err := uuid.Parse(pageToken) + if err != nil { + return nil, business.NewStoreError( + fmt.Errorf("page_token %q is not a cursor this listing issued", pageToken), + business.ErrTypeValidation, + ) + } + cursor = parsed.String() + } + + summaries, err := s.listInstallationSummaries(ctx, executor, orgID, + installationStatusColumn(status), cursor, limit, "TRUE") + if err != nil { + return nil, w.Wrapf(err, "failed to list installations") + } + return summaries, nil +} + +// installationHealthLabel is resolveInstallationHealth as ONE SQL expression over +// an `installations i` row joined to its agent principal as `hp`, so a listing +// resolves health for every row in the same statement instead of three or four +// round trips per row. The per-row form multiplied every menu read by the number +// of entitled installations (#949 review f4). +// +// It must answer exactly what resolveInstallationHealth answers, in the same +// precedence: a missing or revoked agent, then a disabled one, then no current +// admin among the owner and co-owners, then a missing or expired standing grant. +// TestInstallationListingHealthMatchesGetInstallation holds the two together for +// every state, the way the scope point check and listing are held together. +const installationHealthLabel = `CASE + WHEN hp.id IS NULL OR hp.revoked_at IS NOT NULL THEN 'agent_revoked' + WHEN hp.disabled_at IS NOT NULL THEN 'agent_disabled' + WHEN NOT EXISTS ( + SELECT 1 FROM organization_members hm + WHERE hm.org_id = i.org_id + AND hm.user_id = ANY (ARRAY[i.owner_principal_id] || i.co_owner_principal_ids) + AND hm.role IN ('owner', 'admin') + ) THEN 'no_eligible_owner' + WHEN NOT EXISTS ( + SELECT 1 FROM scope_grants hg + JOIN scope_nodes hn ON hn.id = i.root_scope_node_id + WHERE hg.org_id = i.org_id + AND hg.subject_kind = 'principal' + AND hg.subject_id = i.agent_principal_id + AND hg.scope_path @> hn.scope_path + AND (hg.expires_at IS NULL OR hg.expires_at > now()) + ) THEN 'standing_grant_missing' + ELSE 'healthy' + END` + +func installationHealthFromLabel(label string) gen.InstallationHealth { + switch label { + case "agent_revoked": + return gen.InstallationHealth_INSTALLATION_HEALTH_AGENT_REVOKED + case "agent_disabled": + return gen.InstallationHealth_INSTALLATION_HEALTH_AGENT_DISABLED + case "no_eligible_owner": + return gen.InstallationHealth_INSTALLATION_HEALTH_NO_ELIGIBLE_OWNER + case "standing_grant_missing": + return gen.InstallationHealth_INSTALLATION_HEALTH_STANDING_GRANT_MISSING + case "healthy": + return gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY + default: + return gen.InstallationHealth_INSTALLATION_HEALTH_UNSPECIFIED + } +} + +// installationListingColumns is installationColumns qualified by the listing's +// `i` alias, followed by the health label — scanned by scanInstallation plus one. +const installationListingColumns = `i.id::text, i.org_id::text, i.agent_principal_id::text, + i.owner_principal_id::text, i.co_owner_principal_ids::text[], + i.root_scope_node_id::text, i.status, i.created_at, i.revoked_at, + COALESCE(i.target_id::text, ''), COALESCE(i.revoked_reason, ''), ` + installationHealthLabel + +// withHealthLabel scans one listing row: the installation's own columns through +// scanInstallation, so the wire mapping has one definition, then the label. +type withHealthLabel struct { + row rowScanner + label *string +} + +func (r withHealthLabel) Scan(dest ...any) error { + return r.row.Scan(append(dest, r.label)...) +} + +// listInstallationSummaries is the one installation listing both reads go through: +// ListInstallations with no narrowing, and ListSolutionEntitlements narrowed to the +// installations a subject may use. predicate is an extra SQL condition over the +// `i` alias whose placeholders are $1..$len(args); the listing's own parameters are +// numbered after them, so a caller's predicate keeps its own fixed numbering. +// +// id is the keyset: it is the only column unique across statuses — +// (org_id, target_id) is unique only among ACTIVE rows, so a revoked +// install of the same solution would make that cursor skip or repeat a row. +func (s *PostgresStore) listInstallationSummaries( + ctx context.Context, executor QueryExecutor, orgID, statusColumn, cursor string, limit int, + predicate string, args ...any, +) ([]*gen.InstallationSummary, error) { + n := len(args) + org, status, after, limitParam := n+1, n+2, n+3, n+4 + query := fmt.Sprintf(`SELECT %s + FROM installations i + LEFT JOIN principals hp + ON hp.id = i.agent_principal_id AND hp.org_id = i.org_id AND hp.kind = 'agent' + WHERE i.org_id = $%d + AND ($%d = '' OR i.status = $%d) + AND ($%d = '' OR i.id::text > $%d) + AND (%s) + ORDER BY i.id + LIMIT $%d`, + installationListingColumns, org, status, status, after, after, predicate, limitParam) + rows, err := executor.Query(ctx, query, append(args, orgID, statusColumn, cursor, limit)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var summaries []*gen.InstallationSummary + for rows.Next() { + var label string + installation, e := scanInstallation(withHealthLabel{row: rows, label: &label}) + if e != nil { + return nil, e + } + summaries = append(summaries, &gen.InstallationSummary{ + Installation: installation, + Health: installationHealthFromLabel(label), + }) + } + return summaries, rows.Err() +} + // GetInstallation loads one installation and resolves its health live from the // current agent lifecycle, owner admin status, and standing grant. func (s *PostgresStore) GetInstallation(ctx context.Context, orgID, installationID string) (*gen.Installation, gen.InstallationHealth, error) { @@ -669,7 +848,12 @@ func (s *PostgresStore) ListCatalogueInstallations(ctx context.Context) ([]*busi JOIN organizations o ON o.id = i.org_id LEFT JOIN principals p ON p.id = i.agent_principal_id WHERE i.status = 'active') installations - ORDER BY solution_identifier, org_name, id`) + -- target_id, because migration 23 DROPPED solution_identifier. An + -- installation names the immutable target now, and ordering by a + -- column that no longer exists is a hard query error on every fresh + -- database — which is what CI installs, and what a worktree whose + -- pgdata predates the migration does not. + ORDER BY target_id, org_name, id`) if err != nil { return nil, fmt.Errorf("list catalogue installations: %w", err) } diff --git a/module/services/accounts/code/pkg/infra/postgres_installations_test.go b/module/services/accounts/code/pkg/infra/postgres_installations_test.go index b778ed9d3..f9f2c6335 100644 --- a/module/services/accounts/code/pkg/infra/postgres_installations_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_installations_test.go @@ -38,18 +38,25 @@ func installFixture(t *testing.T, resourceKind, action string) (orgID, ownerID, }) })) + // The install names a solution TARGET, so presence has to be declared + // first: there is no free-text identifier a test can invent any more. The + // alias is unique per fixture because the open-target alias index is unique + // and this package shares one database. + alias := uniqueAlias("acme-solution") + targetID := declarePresence(t, "acme.test."+alias, alias) require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { var err error installation, err = testStore.InstallSolution(ctx, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/solution:1.0.0", - SolutionIdentifier: "acme.example/solution", - RootScopeLabel: "Acme Solution", - RoleID: roleID, - OwnerPrincipalID: ownerID, - AllowedAudiences: []string{"acme.collection"}, - AllowedScopes: []string{resourceKind}, - GrantedBy: ownerID, + OrgID: orgID, + AgentIdentifier: "acme.example/solution:1.0.0", + TargetID: targetID, + RouteAlias: alias, + RootScopeLabel: "Acme Solution", + RoleID: roleID, + OwnerPrincipalID: ownerID, + AllowedAudiences: []string{"acme.collection"}, + AllowedScopes: []string{resourceKind}, + GrantedBy: ownerID, }) return err })) @@ -145,12 +152,12 @@ func TestInstallSolutionIsIdempotentPerSolution(t *testing.T) { require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { var err error second, err = testStore.InstallSolution(ctx, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/solution:1.0.0", - SolutionIdentifier: "acme.example/solution", - RootScopeLabel: "Acme Solution", - RoleID: roleID, - OwnerPrincipalID: ownerID, + OrgID: orgID, + AgentIdentifier: "acme.example/solution:1.0.0", + TargetID: first.GetTargetId(), + RootScopeLabel: "Acme Solution", + RoleID: roleID, + OwnerPrincipalID: ownerID, // Same authority envelope as installFixture: an identical re-install // reconciles to a no-op and returns the existing row. AllowedAudiences: []string{"acme.collection"}, @@ -166,16 +173,16 @@ func TestInstallSolutionIsIdempotentPerSolution(t *testing.T) { // return the old row; it fails closed so the change is applied deliberately // (uninstall + reinstall), not lost. func TestInstallSolutionRejectsChangedCeilingOnReinstall(t *testing.T) { - orgID, ownerID, roleID, _, _ := installFixture(t, "doc", "write") + orgID, ownerID, roleID, installed, _ := installFixture(t, "doc", "write") err := testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { _, e := testStore.InstallSolution(ctx, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/solution:1.0.0", - SolutionIdentifier: "acme.example/solution", - RootScopeLabel: "Acme Solution", - RoleID: roleID, - OwnerPrincipalID: ownerID, + OrgID: orgID, + AgentIdentifier: "acme.example/solution:1.0.0", + TargetID: installed.GetTargetId(), + RootScopeLabel: "Acme Solution", + RoleID: roleID, + OwnerPrincipalID: ownerID, // installFixture granted ["doc"]; narrowing to nothing must be rejected. AllowedAudiences: []string{"acme.collection"}, AllowedScopes: nil, @@ -199,15 +206,15 @@ func TestInstallSolutionReinstallWithRevokedStandingGrantConflicts(t *testing.T) err := testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { _, e := testStore.InstallSolution(ctx, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/solution:1.0.0", - SolutionIdentifier: "acme.example/solution", - RootScopeLabel: "Acme Solution", - RoleID: roleID, - OwnerPrincipalID: ownerID, - AllowedAudiences: []string{"acme.collection"}, - AllowedScopes: []string{"doc"}, - GrantedBy: ownerID, + OrgID: orgID, + AgentIdentifier: "acme.example/solution:1.0.0", + TargetID: installation.GetTargetId(), + RootScopeLabel: "Acme Solution", + RoleID: roleID, + OwnerPrincipalID: ownerID, + AllowedAudiences: []string{"acme.collection"}, + AllowedScopes: []string{"doc"}, + GrantedBy: ownerID, }) return e }) @@ -459,13 +466,17 @@ func TestInstallSolutionReusesRootNodeOnReinstall(t *testing.T) { require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { var err error second, err = testStore.InstallSolution(ctx, &business.InstallSolutionParams{ - OrgID: orgID, - AgentIdentifier: "acme.example/solution:2.0.0", - SolutionIdentifier: "acme.example/solution", - RootScopeLabel: "Acme Solution", - RoleID: roleID, - OwnerPrincipalID: ownerID, - GrantedBy: ownerID, + OrgID: orgID, + AgentIdentifier: "acme.example/solution:2.0.0", + // The SAME target. Reinstalling the same presence reuses its + // authority-root node; a REPLACEMENT presence is a different target + // and must not, which is what stops a predecessor's team grants + // from reaching it. + TargetID: first.GetTargetId(), + RootScopeLabel: "Acme Solution", + RoleID: roleID, + OwnerPrincipalID: ownerID, + GrantedBy: ownerID, }) return err })) diff --git a/module/services/accounts/code/pkg/infra/postgres_module_authority.go b/module/services/accounts/code/pkg/infra/postgres_module_authority.go new file mode 100644 index 000000000..02a031284 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_module_authority.go @@ -0,0 +1,126 @@ +package infra + +import ( + "context" + "errors" + "fmt" + + "accounts/pkg/business" + + "github.com/jackc/pgx/v5" +) + +// The live authority read every module capability decision makes. +// +// THIS FILE HELD A REGRESSION THAT BROKE EVERY CAPABILITY PATH, and the shape of +// the mistake is worth more than the fix. +// +// The first version joined `installations.agent_principal_id` against the calling +// module principal. Those are two different identity spaces: an installation's +// agent principal is minted per installation with a fresh uuid +// (`getOrCreateAgentPrincipal`), while a module principal is a DETERMINISTIC uuid +// derived from its declared prefix. The join could never match, so every module +// capability answered PermissionDenied — and the read ran on whatever pool the +// caller's context carried, which for the adapters is the bare request pool where +// RLS hides `installations` outright. +// +// Every test of it faked the read as always-current, so not one could see it. A +// fake that stands in for the thing under test proves the caller, never the +// thing. That is why `TestLiveModuleAuthorityOverARealInstallation` below goes +// through a real `InstallSolution` against real PostgreSQL. +// +// WHAT A MODULE CAPABILITY CAN AND CANNOT BE CHECKED AGAINST: +// +// - PRODUCER EPOCH is checkable. `principal_authorization_revisions` is keyed +// on (org_id, principal_id) and a module principal id is a uuid, so the +// epoch of the calling principal is a real, readable fact. +// - INSTALLATION FRESHNESS is checkable only when the capability NAMES an +// installation. Nothing relates a module principal to an installation — +// an installation is an organisation's consent to a solution TARGET, and the +// module principal is not a party to it. So the host cannot infer which +// installation a module capability acts under; the credential has to say. +// +// Until the seal carries an installation id, that term is therefore SKIPPED +// rather than failed. Skipping an unanswerable question is not the same as +// answering it "yes": the epoch still binds, and `AuthorizeModuleCapability` +// still refuses a credential that names an installation whose live state has +// moved. Failing instead — which is what the regression did — denies every +// legitimate module caller, which is not a stricter check but a broken one. + +// LiveModuleAuthority reads the authority facts for one module principal. +// +// It runs on the CONTROL-PLANE pool explicitly rather than on whatever the +// caller's context carries. `principal_authorization_revisions` and +// `installations` are both granted to `app_control_plane` only, and under the +// request role RLS narrows `installations` to the calling tenant's session — a +// module capability is not exercised inside a tenant session, so the request +// pool returns nothing and the host would read "revoked" from "invisible". +// +// installationID is the installation the CREDENTIAL named, or empty when it +// names none. +func (s *PostgresStore) LiveModuleAuthority( + ctx context.Context, principalID, orgID, installationID string, +) (*business.LiveModuleAuthority, error) { + if orgID == "" { + // Refused before the query, because `$1::uuid` on an empty string is a + // cast error that surfaces as Unavailable — a database fault for what is + // actually a malformed caller. + return nil, fmt.Errorf("%w: no bound organization on the calling capability", business.ErrModuleAuthorityUnreadable) + } + + live := &business.LiveModuleAuthority{} + err := s.WithControlPlane(ctx, func(ctx context.Context) error { + var epoch int64 + // The principal's own epoch, bumped by any narrowing of its envelope. + // COALESCE to 0 because a principal that has never been narrowed has no + // row, and "never narrowed" is epoch 0 rather than an error. + if err := s.getQueryExecutor(ctx).QueryRow(ctx, ` + SELECT COALESCE( + (SELECT revision FROM public.principal_authorization_revisions + WHERE org_id = $1::uuid AND principal_id = $2::uuid), 0)`, + orgID, principalID, + ).Scan(&epoch); err != nil { + return err + } + live.ProducerEpoch = uint64(epoch) + + if installationID == "" { + // The credential names no installation, so there is no installation + // to re-read. See the file comment: this is an unanswerable term, + // not a satisfied one. + return nil + } + + var revision int64 + err := s.getQueryExecutor(ctx).QueryRow(ctx, ` + SELECT i.id::text, COALESCE(o.revision, 0) + FROM public.installations i + LEFT JOIN public.organization_authorization_revisions o + ON o.org_id = i.org_id + WHERE i.id = $1::uuid + AND i.org_id = $2::uuid + AND i.status = 'active'`, + installationID, orgID, + ).Scan(&live.InstallationID, &revision) + if errors.Is(err, pgx.ErrNoRows) { + // Named an installation that is revoked, absent, or belongs to + // another organisation. A verdict, and the one case where the + // installation term genuinely refuses. + return business.ErrModuleInstallationInactive + } + if err != nil { + return err + } + live.InstallationRevision = uint64(revision) + return nil + }) + if errors.Is(err, business.ErrModuleInstallationInactive) { + return nil, business.ErrModuleInstallationInactive + } + if err != nil { + return nil, err + } + return live, nil +} + +var _ business.ModuleAuthorityStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_module_installations.go b/module/services/accounts/code/pkg/infra/postgres_module_installations.go index ed167e091..735f5a307 100644 --- a/module/services/accounts/code/pkg/infra/postgres_module_installations.go +++ b/module/services/accounts/code/pkg/infra/postgres_module_installations.go @@ -20,7 +20,7 @@ func installerConflict(reason string) error { // It serializes the complete compare/create transaction across Accounts replicas. func (s *PostgresStore) ReconcileModuleInstallation(ctx context.Context, p *business.InstallSolutionParams, permissions []string, apply bool) (*business.ModuleInstallationResult, error) { executor := s.getQueryExecutor(ctx) - if err := lockSolutionInstallation(ctx, executor, p.OrgID, p.SolutionIdentifier); err != nil { + if err := lockSolutionInstallation(ctx, executor, p.OrgID, p.TargetID); err != nil { return nil, err } var admin bool @@ -43,7 +43,7 @@ func (s *PostgresStore) ReconcileModuleInstallation(ctx context.Context, p *busi if !sameStringSet(permissions, rolePermissions) { return nil, installerConflict("approved role permissions have changed") } - existing, err := scanInstallation(executor.QueryRow(ctx, `SELECT `+installationColumns+` FROM installations WHERE org_id=$1 AND solution_identifier=$2 ORDER BY created_at DESC LIMIT 1`, p.OrgID, p.SolutionIdentifier)) + existing, err := scanInstallation(executor.QueryRow(ctx, `SELECT `+installationColumns+` FROM installations WHERE org_id=$1 AND target_id=$2::uuid ORDER BY created_at DESC LIMIT 1`, p.OrgID, p.TargetID)) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } diff --git a/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go b/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go index 1a550e365..80453f575 100644 --- a/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go @@ -32,7 +32,13 @@ import ( func boundedInstallationFixture(t *testing.T) *business.InstallSolutionParams { org, owner, role, _, _ := installFixture(t, "documents", "read") - return &business.InstallSolutionParams{OrgID: org, OwnerPrincipalID: owner, GrantedBy: owner, RoleID: role, AgentIdentifier: "acme.example/repeatable:1.0.0", SolutionIdentifier: "repeatable", AllowedAudiences: []string{"example.api"}, AllowedScopes: []string{"documents"}, InstallerPrincipalID: business.ModulePrincipalID("example-installer")} + // Its own declared presence: this is a SECOND installation in the same org, + // so it must be a different target from the fixture's. + alias := uniqueAlias("repeatable") + // `example.api` is a declared MODULE prefix: a ceiling may only name audiences + // this host serves (issue #952), and the installer's path validates it like + // every other write path. installerTestService declares it. + return &business.InstallSolutionParams{OrgID: org, OwnerPrincipalID: owner, GrantedBy: owner, RoleID: role, AgentIdentifier: "acme.example/repeatable:1.0.0", TargetID: declarePresence(t, "acme.test."+alias, alias), RouteAlias: alias, AllowedAudiences: []string{"example.api"}, AllowedScopes: []string{"documents"}, InstallerPrincipalID: business.ModulePrincipalID("example-installer")} } func boundedReconcile(p *business.InstallSolutionParams, apply bool) (out *business.ModuleInstallationResult, err error) { err = testStore.WithOrgTx(testCtx, p.OrgID, func(ctx context.Context) error { @@ -146,13 +152,21 @@ func TestModuleInstallationPostgresHTTPAuthenticationAndAudit(t *testing.T) { require.NoError(t, err) svc := auditedService(t, emitter) svc.SetModuleIdentitySecrets(map[string][sha256.Size]byte{"example-installer": sha256.Sum256([]byte("local-fixture-secret"))}) - svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{p.InstallerPrincipalID: {Prefix: "example-installer", Tenant: p.OrgID}}) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) + // The installer's own principal, plus the audiences its ceiling names: a + // ceiling may only name values this host's vocabulary holds, and the installer + // path validates it like every other write path (issue #952). Declaring + // `example.api` here is how a composition declares it. + svc.SetModuleCapabilities(nil, nil, business.ModulePrincipalRegistry{ + p.InstallerPrincipalID: {Prefix: "example-installer", Tenant: p.OrgID}, + business.ModulePrincipalID("example.api"): {Prefix: "example.api"}, + }) _, private, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) previousAuthority := *adapters.WorkContextSingleton() t.Cleanup(func() { *adapters.WorkContextSingleton() = previousAuthority }) adapters.WorkContextSingleton().Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "installer-test", PrivateKey: private, Authority: testStore}) - policy := business.InstallerPolicy{Version: "accounts.module-installation-policy/v1", Delegations: []business.InstallerDelegation{{Prefix: "example-installer", OrganizationID: p.OrgID, ModuleID: "acme.example/repeatable", AgentIdentifiers: []string{p.AgentIdentifier}, SolutionIdentifier: p.SolutionIdentifier, RoleID: p.RoleID, RolePermissions: []string{"documents:read"}, AllowedAudiences: p.AllowedAudiences, AllowedScopes: p.AllowedScopes, OwnerPrincipalID: p.OwnerPrincipalID, ExpiresAt: time.Now().Add(time.Hour)}}} + policy := business.InstallerPolicy{Version: "accounts.module-installation-policy/v1", Delegations: []business.InstallerDelegation{{Prefix: "example-installer", OrganizationID: p.OrgID, ModuleID: "acme.example/repeatable", AgentIdentifiers: []string{p.AgentIdentifier}, TargetID: p.TargetID, RoleID: p.RoleID, RolePermissions: []string{"documents:read"}, AllowedAudiences: p.AllowedAudiences, AllowedScopes: p.AllowedScopes, OwnerPrincipalID: p.OwnerPrincipalID, ExpiresAt: time.Now().Add(time.Hour)}}} path := filepath.Join(t.TempDir(), "policy.json") raw, err := json.Marshal(policy) require.NoError(t, err) @@ -188,14 +202,67 @@ func TestModuleInstallationPostgresHTTPAuthenticationAndAudit(t *testing.T) { require.NoError(t, json.Unmarshal(raw, &issued)) require.NotEmpty(t, issued.Token) require.WithinDuration(t, time.Now().Add(15*time.Minute), issued.ExpiresAt, 5*time.Second) - request := business.ModuleInstallationRequest{DisplayName: "Example Repeatable", RootScopeLabel: "Example Repeatable", ModuleID: "acme.example/repeatable", OrganizationSlug: "org-" + p.OrgID, AgentIdentifier: p.AgentIdentifier, SolutionIdentifier: p.SolutionIdentifier, RoleID: p.RoleID, ExpectedRolePermissions: []string{"documents:read"}, AllowedAudiences: p.AllowedAudiences, AllowedScopes: p.AllowedScopes} + request := business.ModuleInstallationRequest{DisplayName: "Example Repeatable", RootScopeLabel: "Example Repeatable", ModuleID: "acme.example/repeatable", OrganizationSlug: "org-" + p.OrgID, AgentIdentifier: p.AgentIdentifier, TargetID: p.TargetID, RoleID: p.RoleID, ExpectedRolePermissions: []string{"documents:read"}, AllowedAudiences: p.AllowedAudiences, AllowedScopes: p.AllowedScopes} + + // A ceiling naming an audience this host does not serve is REFUSED AS A + // FORBIDDEN REQUEST, not as an unavailable host, and the refusal names the + // entry. + // + // This is the test the mapping needed. The write-time check (issue #952) is a + // plain error, and this handler's fall-through is 503 "installation + // prerequisite or persistence unavailable" — so without an explicit branch the + // refusal reached the installer as "the host is broken, retry", inviting a + // retry loop that can never succeed and reproducing, one layer out, exactly the + // misleading-cause failure the write-time check exists to end. 503 is also the + // wrong class: the request is what is wrong. + // The DELEGATION AND THE REQUEST AGREE on the unserved audience, which is the + // only shape that reaches this check. With them disagreeing, the delegation + // comparison refuses first with "installer delegation denied" — a 403 for an + // entirely different reason, and a test that accepted it would pass without + // ever exercising the vocabulary. It is also the real-world shape: f2's + // evidence was that `exactSet` CONFIRMS the ceiling against the delegation and + // the write then succeeds. + unserved := request + unserved.AllowedAudiences = []string{"example.api", "nothing-serves-this"} + unservedPolicy := policy + unservedPolicy.Delegations = []business.InstallerDelegation{policy.Delegations[0]} + unservedPolicy.Delegations[0].AllowedAudiences = unserved.AllowedAudiences + unservedPath := filepath.Join(t.TempDir(), "unserved-policy.json") + unservedRaw, err := json.Marshal(unservedPolicy) + require.NoError(t, err) + require.NoError(t, os.WriteFile(unservedPath, unservedRaw, 0600)) + unservedHandler, err := adapters.NewModuleInstallationHTTPHandler(svc, unservedPath) + require.NoError(t, err) + unservedServer := httptest.NewTLSServer(unservedHandler) + defer unservedServer.Close() + + // `inspect` is the dry run, and the check runs there too: the ceiling is + // validated before the transaction opens, so it refuses without writing. + unservedBody, err := json.Marshal(unserved) + require.NoError(t, err) + unservedReq, err := http.NewRequest(http.MethodPost, unservedServer.URL+"/v1/module-installations/inspect", bytes.NewReader(unservedBody)) + require.NoError(t, err) + unservedReq.Header.Set("content-type", "application/json") + unservedReq.Header.Set("X-Codefly-Work-Context", issued.Token) + unservedResponse, err := unservedServer.Client().Do(unservedReq) + require.NoError(t, err) + defer unservedResponse.Body.Close() + refusal, err := io.ReadAll(unservedResponse.Body) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, unservedResponse.StatusCode, string(refusal)) + require.Contains(t, string(refusal), "nothing-serves-this", + "the refusal must name the entry an installer has to fix") + require.NotContains(t, string(refusal), "persistence unavailable", + "a caller's wrong ceiling must never read as this host being unavailable") + require.NotContains(t, string(refusal), "delegation denied", + "an earlier guard firing would make this pass without exercising the vocabulary") if script := os.Getenv("MODULE_INSTALLER_CLIENT_SCRIPT"); script != "" { dir := t.TempDir() caFile, credentialFile, scenarioFile := filepath.Join(dir, "ca.pem"), filepath.Join(dir, "credential"), filepath.Join(dir, "scenario.json") require.NoError(t, os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600)) require.NoError(t, os.WriteFile(credentialFile, []byte("local-fixture-secret"), 0600)) - scenario := map[string]any{"accounts_url": server.URL, "ca_file": caFile, "credential_file": credentialFile, "installer_prefix": "example-installer", "organization_slug": request.OrganizationSlug, "organization_id": p.OrgID, "role_id": p.RoleID, "expected_role_permissions": request.ExpectedRolePermissions, "module_id": request.ModuleID, "agent_identifier": request.AgentIdentifier, "solution_identifier": request.SolutionIdentifier, "allowed_audiences": request.AllowedAudiences, "allowed_scopes": request.AllowedScopes, "display_name": request.DisplayName, "root_scope_label": request.RootScopeLabel} + scenario := map[string]any{"accounts_url": server.URL, "ca_file": caFile, "credential_file": credentialFile, "installer_prefix": "example-installer", "organization_slug": request.OrganizationSlug, "organization_id": p.OrgID, "role_id": p.RoleID, "expected_role_permissions": request.ExpectedRolePermissions, "module_id": request.ModuleID, "agent_identifier": request.AgentIdentifier, "target_id": request.TargetID, "allowed_audiences": request.AllowedAudiences, "allowed_scopes": request.AllowedScopes, "display_name": request.DisplayName, "root_scope_label": request.RootScopeLabel} raw, err := json.Marshal(scenario) require.NoError(t, err) require.NoError(t, os.WriteFile(scenarioFile, raw, 0600)) diff --git a/module/services/accounts/code/pkg/infra/postgres_policy_log.go b/module/services/accounts/code/pkg/infra/postgres_policy_log.go new file mode 100644 index 000000000..99ca67b3a --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_policy_log.go @@ -0,0 +1,171 @@ +package infra + +import ( + "context" + "errors" + "time" + + "accounts/pkg/business" + + "github.com/jackc/pgx/v5" +) + +// The policy log's LOCAL half: receipts and the reconciliation cursor. +// +// The log itself is external, for the reason the protocol exists — a history +// stored beside the state it witnesses is backed up with it, so it cannot +// witness against it. What lives here is the receipt, and the only reachable +// inconsistency is "appended, not applied", which fails closed. +// +// Control-plane only. Neither relation is deletable: a receipt is the evidence +// that an append happened, so deleting one would make an unreconciled gap +// disappear rather than be closed. + +// RecordPolicyLogAppend writes the receipt for an appended operation, with no +// commit yet. +// +// Idempotent on the operation id, and the `DO NOTHING` here is correct where the +// delivery inbox's was not: an operation id is a single logical operation, so a +// second append with the same id IS the same operation and the stored receipt +// is the authoritative one. There are no "different bytes at the same key" to +// distinguish — the log guarantees one entry per operation id, which is why that +// guarantee is the log's and not this host's. +func (s *PostgresStore) RecordPolicyLogAppend( + ctx context.Context, receipt *business.PolicyLogReceipt, entry *business.PolicyLogEntry, +) (bool, error) { + var inserted bool + err := s.getQueryExecutor(ctx).QueryRow(ctx, ` + INSERT INTO public.policy_log_commits + (operation_id, receipt, log_sequence, subject_kind, subject_id, appended_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (operation_id) DO NOTHING + RETURNING TRUE`, + entry.OperationID, receipt.Receipt, int64(receipt.Sequence), + string(entry.SubjectKind), entry.SubjectID, receipt.AppendedAt, + ).Scan(&inserted) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return inserted, nil +} + +// CommitPolicyLogOperation marks an appended operation applied. +// +// It runs in the SAME transaction as the narrowing, which is what makes +// "applied but unwitnessed" impossible. The `committed_at IS NULL` predicate +// makes a double commit a no-op rather than moving the timestamp: the first +// commit is when the narrowing took effect, and a retry must not rewrite that. +// +// A commit for an operation that was never appended is an ERROR, not a silent +// no-op. It would mean a narrowing reached this point without the log having +// witnessed it, which is the one direction the protocol forbids outright. +func (s *PostgresStore) CommitPolicyLogOperation( + ctx context.Context, operationID string, at time.Time, +) error { + tag, err := s.getQueryExecutor(ctx).Exec(ctx, ` + UPDATE public.policy_log_commits + SET committed_at = $2 + WHERE operation_id = $1 AND committed_at IS NULL`, operationID, at) + if err != nil { + return err + } + if tag.RowsAffected() == 1 { + return nil + } + // Either already committed (fine, a retry) or never appended (not fine). + var appended bool + if err := s.getQueryExecutor(ctx).QueryRow(ctx, + `SELECT TRUE FROM public.policy_log_commits WHERE operation_id = $1`, operationID, + ).Scan(&appended); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return business.NewStoreError( + errors.New("cannot commit policy log operation "+operationID+ + ": no append was recorded for it, so the narrowing would be unwitnessed"), + business.ErrTypeConflict) + } + return err + } + return nil +} + +// UncommittedPolicyLogOperations returns appended operations with no commit — +// the gaps the serving gate refuses on. +// +// Ordered by sequence so an operator reading them sees the log's order rather +// than this host's insertion order, which can differ: reconciliation records a +// batch of entries it has just read, and their sequences are the log's. +func (s *PostgresStore) UncommittedPolicyLogOperations( + ctx context.Context, +) ([]*business.PolicyLogGap, error) { + rows, err := s.getQueryExecutor(ctx).Query(ctx, ` + SELECT operation_id, log_sequence, subject_kind, subject_id, appended_at + FROM public.policy_log_commits + WHERE committed_at IS NULL + ORDER BY log_sequence`) + if err != nil { + return nil, err + } + defer rows.Close() + + gaps := []*business.PolicyLogGap{} + for rows.Next() { + var ( + gap business.PolicyLogGap + sequence int64 + kind string + ) + if err := rows.Scan(&gap.OperationID, &sequence, &kind, &gap.SubjectID, &gap.AppendedAt); err != nil { + return nil, err + } + gap.Sequence = uint64(sequence) + gap.SubjectKind = business.PolicyLogSubjectKind(kind) + gaps = append(gaps, &gap) + } + return gaps, rows.Err() +} + +// PolicyLogCursorState reads how far this host has reconciled, and when it last +// reached the log. +func (s *PostgresStore) PolicyLogCursorState( + ctx context.Context, +) (uint64, *time.Time, error) { + var ( + sequence int64 + reachedAt *time.Time + ) + if err := s.getQueryExecutor(ctx).QueryRow(ctx, + `SELECT reconciled_sequence, reached_at FROM public.policy_log_cursor WHERE id = TRUE`, + ).Scan(&sequence, &reachedAt); err != nil { + return 0, nil, err + } + return uint64(sequence), reachedAt, nil +} + +// AdvancePolicyLogCursor records that the log was reached and read to a +// sequence. +// +// The sequence only ever MOVES FORWARD — `GREATEST` rather than assignment. Two +// replicas reconcile independently, so a slower one finishing second must not +// rewind the cursor and cause entries to be re-read and re-recorded as gaps that +// were already closed. +// +// `reached_at` is set unconditionally, because reaching the log is a fact about +// now regardless of whether there was anything new to read. A host that only +// updated it when entries arrived would appear stale during a quiet period and +// would stop serving for lack of news. +func (s *PostgresStore) AdvancePolicyLogCursor( + ctx context.Context, sequence uint64, at time.Time, +) error { + _, err := s.getQueryExecutor(ctx).Exec(ctx, ` + UPDATE public.policy_log_cursor + SET reconciled_sequence = GREATEST(reconciled_sequence, $1), + reached_at = $2, + updated_at = $2 + WHERE id = TRUE`, int64(sequence), at) + return err +} + +var _ business.PolicyLogStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_policy_log_test.go b/module/services/accounts/code/pkg/infra/postgres_policy_log_test.go new file mode 100644 index 000000000..f83ae1832 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_policy_log_test.go @@ -0,0 +1,189 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/stretchr/testify/require" +) + +// The policy log's LOCAL half against real PostgreSQL. +// +// The protocol's ordering and failure handling are tested on fakes, in-package, +// because those are about which write happens first. These are about the SQL the +// fakes cannot prove: that the idempotency key is enforced by the database +// rather than by a check-then-write, that a commit cannot precede an append, +// and that the cursor only moves forward. + +func policyLogEntry(id, subject string) *business.PolicyLogEntry { + return &business.PolicyLogEntry{ + OperationID: id, + Decision: business.PolicyLogNarrowed, + SubjectKind: business.PolicyLogPrincipal, + SubjectID: subject, + Actor: "admin-1", + } +} + +func policyLogReceipt(id string, sequence uint64, at time.Time) *business.PolicyLogReceipt { + return &business.PolicyLogReceipt{ + Receipt: "receipt-" + id, + Sequence: sequence, + AppendedAt: at, + } +} + +// An append then a commit: the row goes from gap to applied. +func TestPolicyLogAppendThenCommit(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + operation := "op-" + business.NewIDString() + subject := business.NewIDString() + + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + inserted, err := testStore.RecordPolicyLogAppend(ctx, + policyLogReceipt(operation, 101, now), policyLogEntry(operation, subject)) + require.NoError(t, err) + require.True(t, inserted) + + gaps, err := testStore.UncommittedPolicyLogOperations(ctx) + require.NoError(t, err) + require.True(t, containsPolicyGap(gaps, operation), + "an appended, uncommitted operation must read as a gap") + + require.NoError(t, testStore.CommitPolicyLogOperation(ctx, operation, now)) + + gaps, err = testStore.UncommittedPolicyLogOperations(ctx) + require.NoError(t, err) + require.False(t, containsPolicyGap(gaps, operation), + "a committed operation is no longer a gap") + return nil + })) +} + +// The idempotency key is enforced by the DATABASE. A second append of the same +// operation writes nothing and reports not-inserted, so a retry cannot produce +// two receipts for one revocation. +func TestPolicyLogAppendIsIdempotentOnTheOperationID(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + operation := "op-" + business.NewIDString() + + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + first, err := testStore.RecordPolicyLogAppend(ctx, + policyLogReceipt(operation, 201, now), policyLogEntry(operation, business.NewIDString())) + require.NoError(t, err) + require.True(t, first) + + second, err := testStore.RecordPolicyLogAppend(ctx, + policyLogReceipt(operation, 202, now), policyLogEntry(operation, business.NewIDString())) + require.NoError(t, err) + require.False(t, second, "a retry of one operation must not append twice") + return nil + })) +} + +// A commit for an operation that was never appended is an ERROR, not a quiet +// no-op. +// +// It would mean a narrowing reached the commit without the log having witnessed +// it, which is the one direction the protocol forbids outright — so it must be +// loud rather than absorbed. +func TestPolicyLogCommitWithoutAnAppendIsRefused(t *testing.T) { + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + err := testStore.CommitPolicyLogOperation(ctx, "op-never-appended-"+business.NewIDString(), time.Now().UTC()) + require.Error(t, err, "committing an unwitnessed narrowing must be refused") + return nil + })) +} + +// Committing twice is a no-op rather than a rewrite: the first commit is when +// the narrowing took effect, and a retry must not move that timestamp. +func TestPolicyLogDoubleCommitDoesNotRewriteTheTimestamp(t *testing.T) { + first := time.Now().UTC().Truncate(time.Millisecond) + later := first.Add(time.Hour) + operation := "op-" + business.NewIDString() + + // The write and the read-back are SEPARATE transactions, deliberately. The + // read-back helper opens its own control-plane transaction, so performing it + // inside the writing one would read a snapshot taken before the insert + // committed — a first draft did exactly that and failed with "no rows", + // which is the isolation working rather than the store misbehaving. + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + _, err := testStore.RecordPolicyLogAppend(ctx, + policyLogReceipt(operation, 301, first), policyLogEntry(operation, business.NewIDString())) + require.NoError(t, err) + require.NoError(t, testStore.CommitPolicyLogOperation(ctx, operation, first)) + require.NoError(t, testStore.CommitPolicyLogOperation(ctx, operation, later), + "a second commit is a retry, not an error") + return nil + })) + + var committed time.Time + require.NoError(t, testStore.ScanAsControlPlane(testCtx, + `SELECT committed_at FROM public.policy_log_commits WHERE operation_id = $1`, + []any{&committed}, operation)) + require.WithinDuration(t, first, committed, time.Second, + "the commit time is when the narrowing took effect, not when it was retried") +} + +// The cursor only moves FORWARD. +// +// Two replicas reconcile independently, so a slower one finishing second must +// not rewind the cursor — which would cause entries to be re-read and recorded +// again as gaps that had already been closed, stopping both hosts serving for a +// reason neither could resolve. +func TestPolicyLogCursorOnlyMovesForward(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + require.NoError(t, testStore.AdvancePolicyLogCursor(ctx, 500, now)) + sequence, reachedAt, err := testStore.PolicyLogCursorState(ctx) + require.NoError(t, err) + require.Equal(t, uint64(500), sequence) + require.NotNil(t, reachedAt) + + // A slower replica reports an older sequence. + require.NoError(t, testStore.AdvancePolicyLogCursor(ctx, 400, now.Add(time.Second))) + sequence, _, err = testStore.PolicyLogCursorState(ctx) + require.NoError(t, err) + require.Equal(t, uint64(500), sequence, "a lagging replica must not rewind the cursor") + return nil + })) +} + +// Reaching the log is recorded even when there was nothing new to read. +// +// A host that only stamped `reached_at` when entries arrived would look stale +// during a quiet period and would stop serving for lack of news — turning an +// absence of authority changes into an outage. +func TestPolicyLogCursorRecordsContactWithNoNewEntries(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + sequence, _, err := testStore.PolicyLogCursorState(ctx) + require.NoError(t, err) + + later := now.Add(2 * time.Minute) + require.NoError(t, testStore.AdvancePolicyLogCursor(ctx, sequence, later)) + got, reachedAt, err := testStore.PolicyLogCursorState(ctx) + require.NoError(t, err) + require.Equal(t, sequence, got, "no new entries means no new sequence") + require.NotNil(t, reachedAt) + require.WithinDuration(t, later, *reachedAt, time.Second, + "but contact with the log is still a fact about now") + return nil + })) +} + +func containsPolicyGap(gaps []*business.PolicyLogGap, operation string) bool { + for _, gap := range gaps { + if gap.OperationID == operation { + return true + } + } + return false +} diff --git a/module/services/accounts/code/pkg/infra/postgres_principals_test.go b/module/services/accounts/code/pkg/infra/postgres_principals_test.go index e9337ccd2..09e607e9d 100644 --- a/module/services/accounts/code/pkg/infra/postgres_principals_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_principals_test.go @@ -498,6 +498,9 @@ func TestPrincipals_CreateAgent_AuditActorTypeFromCreatorKind(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -526,6 +529,9 @@ func TestPrincipals_RevokePrincipal_EmitsAuditEvent(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -552,6 +558,17 @@ func TestPrincipals_CreateAgent_PersistsCeiling(t *testing.T) { orgID := seedOrg(t, owner) svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) + // An agent ceiling may only name audiences this host SERVES (issue #952), so + // these are declared module prefixes rather than the free text the field used + // to admit — declared the way a composition declares them. `github` and `jira` + // named no consumer on this host and would now be refused at write. + svc.SetModulePrincipals(business.ModulePrincipalRegistry{ + business.ModulePrincipalID("github"): {Prefix: "github"}, + business.ModulePrincipalID("jira"): {Prefix: "jira"}, + }) created, err := svc.CreateAgentPrincipal(testCtx, business.CreateAgentRequest{ OrgID: orgID, @@ -564,6 +581,19 @@ func TestPrincipals_CreateAgent_PersistsCeiling(t *testing.T) { require.Equal(t, []string{"github", "jira"}, created.AllowedAudiences) require.Equal(t, []string{"repo"}, created.AllowedScopes) + // And an audience this host does not serve is REFUSED at write, naming it. + // Without this the case above only proves the validation can be satisfied, not + // that it discriminates. + _, err = svc.CreateAgentPrincipal(testCtx, business.CreateAgentRequest{ + OrgID: orgID, + AgentIdentifier: "publisher/unserved:1.0.0", + CreatedBy: owner, + AllowedAudiences: []string{"github", "nothing-serves-this"}, + AllowedScopes: []string{"repo"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "nothing-serves-this") + got, err := testStore.As(business.Identity{OrgID: orgID}).GetAgentPrincipal(testCtx, orgID, "publisher/ceiling:1.0.0") require.NoError(t, err) require.Equal(t, []string{"github", "jira"}, got.AllowedAudiences) @@ -588,6 +618,9 @@ func TestPrincipals_DisableEnable_LifecycleAndAudit(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -618,6 +651,9 @@ func TestPrincipals_Disable_IdempotentNoDuplicateAudit(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) rec := &recordingEmitter{} svc.SetAuditEmitter(rec) @@ -666,6 +702,9 @@ func TestPrincipals_Enable_RejectedForRevoked(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) require.NoError(t, testStore.As(business.System()).RevokePrincipal(testCtx, agent.ID, "terminal")) err = svc.EnableAgentPrincipal(testCtx, agent.ID) diff --git a/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go b/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go index f3aaff9c8..fc3187f3e 100644 --- a/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_role_hardening_test.go @@ -37,19 +37,32 @@ var externalRelationAuthorities = map[string]externalRelationAuthority{} var appTenantRelationPrivileges = map[string]relationPrivileges{ // Global catalogs and worker-owned job relations. - "identity_providers": {selectRows: true}, - "plans": {selectRows: true}, - "plan_entitlements": {selectRows: true}, - "email_templates": {selectRows: true}, - "data_retention_policies": {selectRows: true}, - "bootstrap_state": {selectRows: true}, // the bootstrap claim runs on the control plane - "feature_flags": {selectRows: true}, - "audit_event_types": {selectRows: true}, - "platform_admins": {selectRows: true}, // granting and revoking run on the control plane - "analytics_deliveries": {}, - "email_delivery_events": {}, - "event_subscriptions": {}, // platform relation; request traffic has no direct access - "solution_registrations": {}, // platform relation; request traffic has no direct access + "identity_providers": {selectRows: true}, + "plans": {selectRows: true}, + "plan_entitlements": {selectRows: true}, + "email_templates": {selectRows: true}, + "data_retention_policies": {selectRows: true}, + "bootstrap_state": {selectRows: true}, // the bootstrap claim runs on the control plane + "feature_flags": {selectRows: true}, + "audit_event_types": {selectRows: true}, + "platform_admins": {selectRows: true}, // granting and revoking run on the control plane + "analytics_deliveries": {}, + "email_delivery_events": {}, + "event_subscriptions": {}, // platform relation; request traffic has no direct access + "solution_registrations": {}, // platform relation; request traffic has no direct access + "solution_host_bindings": {}, // platform relation; only the reconcile pass writes it + "solution_targets": {}, // platform relation; the identity an installation names + "solution_generation_history": {}, // platform relation; append-only decision trail + // The delivery inbox: every signed carrier this host was handed. Request + // traffic has no access, and the control plane holds SELECT and INSERT only + // — no UPDATE and no DELETE, because an inbox that can be edited is not a + // record of what was received. + "solution_delivery_documents": {}, + // The policy log's local half. Request traffic has no access at all: a + // receipt is evidence about authority, and the request path neither reads + // nor writes it. + "policy_log_commits": {}, + "policy_log_cursor": {}, "datasource_credential_budgets": {}, // platform relation; the control plane meters provider credentials "job_attempts": {}, "job_messages": {}, @@ -377,6 +390,44 @@ func TestControlPlaneRelationGrantsAreExact(t *testing.T) { if relation == "approval_requests" || relation == "source_read_revisions" { want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} } + // solution_generation_history is APPEND-ONLY: read and insert, never + // update or delete. A decision is a fact about the past, and a trail + // whose rows can be edited is not a trail. + if relation == "solution_generation_history" { + want = relationPrivileges{selectRows: true, insertRows: true} + } + // solution_delivery_documents is APPEND-ONLY, and more strictly so + // than the decision trail: read and insert, never update and never + // delete. It holds the signed carriers this host was handed, so an + // inbox whose rows can be edited is not a record of what was + // received — and the carrier is what a restore re-verifies against, + // which is only worth anything if nothing could have rewritten it. + // A correction is a new generation. + if relation == "solution_delivery_documents" { + want = relationPrivileges{selectRows: true, insertRows: true} + } + // policy_log_commits and policy_log_cursor are read, inserted and + // updated, never deleted. The commit row is completed by the commit + // and the cursor advances — but deleting either would make an + // unreconciled gap DISAPPEAR rather than be closed, which is the one + // way the fail-closed asymmetry could be defeated locally. + if relation == "policy_log_commits" || relation == "policy_log_cursor" { + want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} + } + // solution_targets is never row-deleted either: a CLOSED target is the + // evidence that an installation's consent ended, and deleting it + // would make a reused alias indistinguishable from a continuous one. + if relation == "solution_targets" { + want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} + } + // solution_host_bindings is never row-deleted: removal of a declared + // solution is a tombstone GENERATION on the row, so the binding ID and + // its applied generation survive and a late or replayed older + // generation still collides with them. The control plane reads, + // inserts and updates. + if relation == "solution_host_bindings" { + want = relationPrivileges{selectRows: true, insertRows: true, updateRows: true} + } // source_delegations are revoked, never deleted: the record of what a // person delegated outlives the source and the person it names. // Organization deletion removes them through the FK cascade. diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go b/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go new file mode 100644 index 000000000..7206b2346 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_delivery.go @@ -0,0 +1,128 @@ +package infra + +import ( + "bytes" + "context" + "errors" + "fmt" + + "accounts/pkg/business" + + "github.com/jackc/pgx/v5" +) + +// The delivery inbox's persistence. +// +// solution_delivery_documents is a control-plane-owned platform relation (no +// tenant column, no RLS), and it is granted SELECT and INSERT only — no UPDATE +// and no DELETE. An inbox that can be edited is not a record of what was +// received, so a correction is a new generation rather than a rewrite. Every +// method assumes the caller opened a WithControlPlane transaction. + +const solutionDeliveryColumns = `kind, document_id, generation, content_hash, + payload, bundle, signer_identity, ownership_domain, received_at` + +// RecordDeliveredDocument inserts one received carrier, and reports whether the +// row was new. +// +// Idempotency is the whole of this method. The unique index on +// (kind, document_id, generation) means a second delivery at the same generation +// collides, and the collision has two meanings that must NOT be conflated: +// +// - the same bytes again — a retry, or a re-sync of an unchanged tree. Writes +// nothing, reports not-inserted, and the caller answers 200. +// - DIFFERENT bytes at a generation already delivered — a rewrite of a +// delivered generation, which publish never produces. Refused as +// ErrSolutionDeliveryRewritten, and the caller answers 409 so an operator +// sees it as the hand-edited delivery tree it is. +// +// `ON CONFLICT DO NOTHING` alone could not tell those apart: it reports no row +// written for both, and the second case would then read as a successful replay +// while the host kept serving different bytes than delivery believes it sent. +// So the conflict is resolved by reading the stored content hash back. +func (s *PostgresStore) RecordDeliveredDocument( + ctx context.Context, record *business.SolutionDeliveryRecord, +) (bool, error) { + executor := s.getQueryExecutor(ctx) + + var storedHash string + var storedPayload []byte + err := executor.QueryRow(ctx, ` + INSERT INTO public.solution_delivery_documents (`+solutionDeliveryColumns+`) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (kind, document_id, generation) DO NOTHING + RETURNING content_hash, payload`, + string(record.Kind), record.DocumentID, int64(record.Generation), record.ContentHash, + record.Payload, record.Bundle, record.SignerIdentity, record.OwnershipDomain, record.ReceivedAt, + ).Scan(&storedHash, &storedPayload) + if err == nil { + return true, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return false, err + } + + // The insert was a no-op, so this generation already exists. Which of the + // two meanings it has depends on the bytes. + if err := executor.QueryRow(ctx, ` + SELECT content_hash, payload FROM public.solution_delivery_documents + WHERE kind = $1 AND document_id = $2 AND generation = $3`, + string(record.Kind), record.DocumentID, int64(record.Generation), + ).Scan(&storedHash, &storedPayload); err != nil { + return false, err + } + // The hash is compared AND the bytes are, rather than the hash alone. The + // hash is what the schema constrains and what an index could be built on, + // but it is also a value a writer supplies: comparing the payload as well + // means a collision cannot be talked past by sending a matching hash with + // different content. + if storedHash == record.ContentHash && bytes.Equal(storedPayload, record.Payload) { + return false, nil + } + return false, fmt.Errorf("%w: %s %s generation %d was delivered as %s and is now offered as %s", + business.ErrSolutionDeliveryRewritten, + record.Kind, record.DocumentID, record.Generation, storedHash, record.ContentHash) +} + +// ListNewestDeliveredDocuments returns the newest generation per document for +// one kind: the desired set. +// +// DISTINCT ON over (document_id) ordered by generation DESC, which is the +// newest-per-key read the index `solution_delivery_documents_newest` exists for. +// Superseded generations stay in the table — they are the evidence of what was +// delivered — and are deliberately not returned: the desired set is what +// delivery wants NOW, and handing the reconciler every generation would make it +// judge a set containing its own history. +func (s *PostgresStore) ListNewestDeliveredDocuments( + ctx context.Context, kind business.SolutionDeliveryKind, +) ([]*business.SolutionDeliveryRecord, error) { + rows, err := s.getQueryExecutor(ctx).Query(ctx, ` + SELECT DISTINCT ON (document_id) `+solutionDeliveryColumns+` + FROM public.solution_delivery_documents + WHERE kind = $1 + ORDER BY document_id, generation DESC`, string(kind)) + if err != nil { + return nil, err + } + defer rows.Close() + + records := []*business.SolutionDeliveryRecord{} + for rows.Next() { + var ( + record business.SolutionDeliveryRecord + kindText string + generation int64 + ) + if err := rows.Scan(&kindText, &record.DocumentID, &generation, &record.ContentHash, + &record.Payload, &record.Bundle, &record.SignerIdentity, &record.OwnershipDomain, + &record.ReceivedAt); err != nil { + return nil, err + } + record.Kind = business.SolutionDeliveryKind(kindText) + record.Generation = uint64(generation) + records = append(records, &record) + } + return records, rows.Err() +} + +var _ business.SolutionDeliveryStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go new file mode 100644 index 000000000..92e8ad48f --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go @@ -0,0 +1,102 @@ +package infra + +import ( + "context" + "fmt" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/wool" + "github.com/google/uuid" +) + +// ListSolutionEntitlements answers what one subject may use inside one org +// (issue #949): the org's active installations, narrowed to those whose +// authority-root scope node the subject may act on. +// +// It is ONE statement on purpose. The installation set and the grant + share +// union are two authority facts, and reading them in two round trips lets a +// revoke land between them — producing a menu entry whose grant is already gone, +// and a fingerprint describing a state that never existed. Joined here, both are +// read at one snapshot inside the caller's org transaction. +// +// The admitted-scope subquery is `accessibleScopesQuery`, the same union +// CheckAccess and ListAccessibleScopes resolve through — not a second predicate +// that means to agree with it. That is what makes it impossible for this listing +// to offer a solution an authority check would then deny. +// +// The join is on `n.id::text = root_scope_node_id::text`, the projection the scope +// listing itself returns as node_id, for the reason CanReadScopeNode compares +// there: it keeps membership and listing reading one set by construction. +func (s *PostgresStore) ListSolutionEntitlements( + ctx context.Context, orgID, subjectID string, pageToken string, limit int, +) ([]*gen.SolutionEntitlement, error) { + w := wool.Get(ctx).In("ListSolutionEntitlements", wool.Field("org_id", orgID)) + executor := s.getQueryExecutor(ctx) + + cursor := "" + if pageToken != "" { + parsed, err := uuid.Parse(pageToken) + if err != nil { + return nil, business.NewStoreError( + fmt.Errorf("page_token %q is not a cursor this listing issued", pageToken), + business.ErrTypeValidation, + ) + } + cursor = parsed.String() + } + + // $1 subject, $2 resource_type, $3 action, $4 org, $5 the node kind this + // narrows to, $6 platform admissibility — the parameter contract + // accessibleScopesQuery documents. The shared listing numbers its own + // parameters after these. + // + // The node predicate is the node KIND rather than a candidate id set: a + // solution's authority root is the one node per install of kind `solution`, so + // asking the union for accessible solution nodes is the question, and it needs + // no array of ids assembled by a prior query. + admitted, err := accessibleScopesQuery( + gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, "node_id", "n.kind = $5", "$6") + if err != nil { + return nil, err + } + + // Only ACTIVE installations. A revoked installation's standing grant is gone, + // so the union would usually exclude it anyway — but "usually" is not a + // boundary: a team grant written directly at the solution node outlives the + // uninstall (the node is retained for a reinstall to reuse), and without this + // predicate an uninstalled solution would stay in a granted team's menu. + // + // Through the SAME listing ListInstallations reads, narrowed by the admitted + // set, so the two reads share one query, one keyset and one health definition, + // and health for every row is resolved inside this one statement. + summaries, err := s.listInstallationSummaries(ctx, executor, orgID, "active", cursor, limit, + "i.root_scope_node_id::text IN ("+admitted+")", + subjectID, business.ResourceTypeSolution, business.ActionUseSolution, orgID, + business.ScopeNodeKindSolution, + platformReadAdmissible(ctx, gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, business.ActionUseSolution)) + if err != nil { + return nil, w.Wrapf(err, "failed to list solution entitlements") + } + + out := make([]*gen.SolutionEntitlement, 0, len(summaries)) + for _, summary := range summaries { + installation := summary.GetInstallation() + out = append(out, &gen.SolutionEntitlement{ + // The immutable target, never the route alias: a consumer resolves + // the alias it was asked about through applied presence state and + // compares this id, so a replacement binding that claimed a + // withdrawn alias cannot match an installation of its predecessor. + TargetId: installation.GetTargetId(), + InstallationId: installation.GetId(), + RootScopeNodeId: installation.GetRootScopeNodeId(), + // Anything that is not HEALTHY is not healthy: the reduction survives + // the health enum growing, which a copy of its value set would not. + Healthy: summary.GetHealth() == gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, + }) + } + return out, nil +} + +var _ business.SolutionEntitlementStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go new file mode 100644 index 000000000..5d7f79766 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go @@ -0,0 +1,562 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "strings" + "testing" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" +) + +// These are the acceptance cases of issue #949, at the layer that decides them. +// A viewer sees the solutions their organization installed and their teams were +// granted, and nothing else — so the tests that matter are the ones where two +// subjects in ONE organization, over ONE registered set, read different answers. + +// solutionRole creates a role permitting (solution, use) — the pair a +// solution-visibility question is asked in. +func solutionRole(t *testing.T, orgID string) string { + t.Helper() + roleID := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.CreateRole(ctx, &gen.Role{ + Id: roleID, + Name: "solution user " + roleID, + OrgId: orgID, + Permissions: []*gen.Permission{{ + Resource: business.ResourceTypeSolution, + Action: business.ActionUseSolution, + }}, + }) + })) + return roleID +} + +// installSolution declares presence for one alias and installs the target it +// opens, returning the installation. The agent's own standing grant is written at +// its root node; no team grant is, which is the "no implicit grants" property +// several tests below rest on. +// +// It declares presence first because an installation names a target, and a target +// exists only where the host applied a present generation. The binding id is +// derived from the alias so each alias gets its own binding, which is what lets a +// test hand one alias from one binding to another. +func installSolution(t *testing.T, orgID, ownerID, roleID, identifier string) *gen.Installation { + t.Helper() + alias := uniqueAlias(identifier) + return installSolutionTarget(t, orgID, ownerID, roleID, identifier, + declarePresence(t, "acme.test."+alias, alias)) +} + +// installSolutionTarget installs one already-declared target, so a test can +// install the SAME target twice, or install a replacement target that claimed a +// predecessor's alias. +func installSolutionTarget(t *testing.T, orgID, ownerID, roleID, label, targetID string) *gen.Installation { + t.Helper() + var installation *gen.Installation + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + var err error + installation, err = testStore.InstallSolution(ctx, &business.InstallSolutionParams{ + OrgID: orgID, + AgentIdentifier: "acme.example/" + label + ":1.0.0", + TargetID: targetID, + RouteAlias: label, + RootScopeLabel: label, + RoleID: roleID, + OwnerPrincipalID: ownerID, + GrantedBy: ownerID, + }) + return err + })) + return installation +} + +// seedTeamWith creates a team holding member, and returns its id. +func seedTeamWith(t *testing.T, orgID, member string) string { + t.Helper() + teamID := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + if err := testStore.CreateTeam(ctx, &gen.Team{ + Id: teamID, OrgId: orgID, Name: "Team " + teamID, + Slug: "team-" + teamID, + // The whole id, not a prefix of it: teams.path is unique per org and a + // UUIDv7's leading characters are a timestamp, so two teams created in + // the same millisecond would collide on any short prefix. + Path: "team_" + strings.ReplaceAll(teamID, "-", "_"), + }); err != nil { + return err + } + return testStore.AddTeamMember(ctx, teamID, member, "member") + })) + return teamID +} + +// grantAtNode grants roleID to a team at an installation's authority-root node. +func grantAtNode(t *testing.T, orgID, teamID, roleID string, installation *gen.Installation) { + t.Helper() + path := scopeNodePath(t, orgID, installation.GetRootScopeNodeId()) + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.GrantScope(ctx, &gen.ScopeGrant{ + Id: business.NewIDString(), OrgId: orgID, SubjectId: teamID, + SubjectKind: gen.SubjectKind_SUBJECT_KIND_TEAM, ScopePath: path, RoleId: roleID, + }) + })) +} + +func revokeAtNode(t *testing.T, orgID, teamID, roleID string, installation *gen.Installation) { + t.Helper() + path := scopeNodePath(t, orgID, installation.GetRootScopeNodeId()) + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.RevokeScope(ctx, orgID, teamID, + gen.SubjectKind_SUBJECT_KIND_TEAM, path, roleID) + })) +} + +// entitledIdentifiers is what a viewer may use, as the store answers it. +func entitledIdentifiers(t *testing.T, orgID, subjectID string) []string { + t.Helper() + var out []*gen.SolutionEntitlement + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + var err error + out, err = testStore.ListSolutionEntitlements(ctx, orgID, subjectID, "", 100) + return err + })) + identifiers := make([]string, 0, len(out)) + for _, entitlement := range out { + identifiers = append(identifiers, entitlement.GetTargetId()) + } + return identifiers +} + +// An organization member with a personal org and admin standing, ready to install. +func entitlementOrg(t *testing.T) (orgID, ownerID, roleID string) { + t.Helper() + ownerID = seedUser(t) + seedHumanPrincipal(t, ownerID, "Installer Admin") + orgID = seedOrg(t, ownerID) + require.NoError(t, testStore.As(business.Identity{OrgID: orgID}).AddOrgMember(testCtx, ownerID, "admin")) + return orgID, ownerID, solutionRole(t, orgID) +} + +// THE acceptance case: two teams in one organization, one registered set, and +// genuinely different answers. If this passes with both teams seeing everything, +// the projection is still deployment-wide. +func TestListSolutionEntitlements_TwoTeamsReadDifferentSolutions(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + ledger := installSolution(t, orgID, ownerID, roleID, "ledger") + + reviewer := seedUser(t) + seedHumanPrincipal(t, reviewer, "Reviewer") + seedOrgMember(t, orgID, reviewer) + clerk := seedUser(t) + seedHumanPrincipal(t, clerk, "Clerk") + seedOrgMember(t, orgID, clerk) + + reviewers := seedTeamWith(t, orgID, reviewer) + clerks := seedTeamWith(t, orgID, clerk) + grantAtNode(t, orgID, reviewers, roleID, audit) + grantAtNode(t, orgID, clerks, roleID, ledger) + + require.Equal(t, []string{audit.GetTargetId()}, entitledIdentifiers(t, orgID, reviewer)) + require.Equal(t, []string{ledger.GetTargetId()}, entitledIdentifiers(t, orgID, clerk)) +} + +// Installing reaches no team. The install writes a standing grant for the AGENT +// principal only, so until an admin writes a team grant the solution is invisible +// — there is no implicit or inherited admission. +func TestListSolutionEntitlements_FreshInstallReachesNoTeam(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + installSolution(t, orgID, ownerID, roleID, "audit") + + member := seedUser(t) + seedHumanPrincipal(t, member, "Member") + seedOrgMember(t, orgID, member) + seedTeamWith(t, orgID, member) + + require.Empty(t, entitledIdentifiers(t, orgID, member), + "a newly installed solution must reach no team until a grant is written") +} + +// The projection narrows when a grant is revoked, with nothing about the +// registration or the installation changing. +func TestListSolutionEntitlements_NarrowsAfterRevocation(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + ledger := installSolution(t, orgID, ownerID, roleID, "ledger") + + member := seedUser(t) + seedHumanPrincipal(t, member, "Member") + seedOrgMember(t, orgID, member) + team := seedTeamWith(t, orgID, member) + grantAtNode(t, orgID, team, roleID, audit) + grantAtNode(t, orgID, team, roleID, ledger) + require.ElementsMatch(t, + []string{audit.GetTargetId(), ledger.GetTargetId()}, + entitledIdentifiers(t, orgID, member)) + + revokeAtNode(t, orgID, team, roleID, ledger) + require.Equal(t, []string{audit.GetTargetId()}, entitledIdentifiers(t, orgID, member), + "a revoked grant must remove the solution from the projection") +} + +// A role that does not permit (solution, use) reveals nothing. This is what keeps +// a grant written for some other purpose from quietly adding a menu entry. +func TestListSolutionEntitlements_UnrelatedRoleRevealsNothing(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + + member := seedUser(t) + seedHumanPrincipal(t, member, "Member") + seedOrgMember(t, orgID, member) + team := seedTeamWith(t, orgID, member) + + unrelated := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.CreateRole(ctx, &gen.Role{ + Id: unrelated, Name: "doc reader " + unrelated, OrgId: orgID, + Permissions: []*gen.Permission{{Resource: "doc", Action: "read"}}, + }) + })) + grantAtNode(t, orgID, team, unrelated, audit) + + require.Empty(t, entitledIdentifiers(t, orgID, member)) +} + +// An uninstalled solution is invisible even to a team whose grant at the node +// outlives the uninstall — the node is retained for a reinstall to reuse, so the +// grant can still be there. Without the status predicate this is the case that +// leaks. +func TestListSolutionEntitlements_UninstalledIsInvisibleDespiteALingeringGrant(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + + member := seedUser(t) + seedHumanPrincipal(t, member, "Member") + seedOrgMember(t, orgID, member) + team := seedTeamWith(t, orgID, member) + grantAtNode(t, orgID, team, roleID, audit) + require.Equal(t, []string{audit.GetTargetId()}, entitledIdentifiers(t, orgID, member)) + + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, _, err := testStore.UninstallSolution(ctx, orgID, audit.GetId()) + return err + })) + + require.Empty(t, entitledIdentifiers(t, orgID, member), + "an uninstalled solution must not stay in a granted team's projection") +} + +// An unhealthy installation stays entitled and reports healthy=false. It is still +// listed by the consumer — the org installed it and the viewer was granted it — +// and simply not routed as serving. +func TestListSolutionEntitlements_UnhealthyStaysEntitled(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + + member := seedUser(t) + seedHumanPrincipal(t, member, "Member") + seedOrgMember(t, orgID, member) + team := seedTeamWith(t, orgID, member) + grantAtNode(t, orgID, team, roleID, audit) + + read := func() []*gen.SolutionEntitlement { + t.Helper() + var out []*gen.SolutionEntitlement + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + var err error + out, err = testStore.ListSolutionEntitlements(ctx, orgID, member, "", 100) + return err + })) + return out + } + healthy := read() + require.Len(t, healthy, 1) + require.True(t, healthy[0].GetHealthy()) + + // Drop the owner of record out of org admin: the installation goes + // NO_ELIGIBLE_OWNER, which is unhealthy without being uninstalled. + require.NoError(t, testStore.As(business.Identity{OrgID: orgID}).AddOrgMember(testCtx, ownerID, "member")) + + unhealthy := read() + require.Len(t, unhealthy, 1, "an unhealthy installation stays in the projection") + require.False(t, unhealthy[0].GetHealthy(), "and reports that it is not healthy") +} + +// One organization's installations never reach another's viewer, whatever grants +// exist. The org predicate plus the RLS floor both pin it. +func TestListSolutionEntitlements_IsTenantIsolated(t *testing.T) { + orgA, ownerA, roleA := entitlementOrg(t) + installedA := installSolution(t, orgA, ownerA, roleA, "audit") + memberA := seedUser(t) + seedHumanPrincipal(t, memberA, "Member A") + seedOrgMember(t, orgA, memberA) + teamA := seedTeamWith(t, orgA, memberA) + grantAtNode(t, orgA, teamA, roleA, installedA) + + orgB, ownerB, roleB := entitlementOrg(t) + installSolution(t, orgB, ownerB, roleB, "ledger") + + require.Equal(t, []string{installedA.GetTargetId()}, entitledIdentifiers(t, orgA, memberA)) + // Asked about org B, the SAME viewer is entitled to nothing: they hold no + // grant there and are not a member. + require.Empty(t, entitledIdentifiers(t, orgB, memberA)) +} + +// A page token this listing never issued is a validation error, not a 500 and not +// a silently empty page. `id` is a uuid column, so binding an arbitrary string +// raw would abort the transaction on a cast. +func TestListSolutionEntitlements_RejectsAForeignCursor(t *testing.T) { + orgID, _, _ := entitlementOrg(t) + err := testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, e := testStore.ListSolutionEntitlements(ctx, orgID, business.NewIDString(), "not-a-cursor", 10) + return e + }) + require.Error(t, err) + require.Contains(t, err.Error(), "page_token") +} + +// ============================================================================ +// ListInstallations — the org-scoped listing the entitlement read is built on +// ============================================================================ + +func listInstallations(t *testing.T, orgID string, status gen.InstallationStatus, token string, limit int) []*gen.InstallationSummary { + t.Helper() + var out []*gen.InstallationSummary + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + var err error + out, err = testStore.ListInstallations(ctx, orgID, status, token, limit) + return err + })) + return out +} + +func TestListInstallations_ReturnsTheOrgsInstallationsWithHealth(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + ledger := installSolution(t, orgID, ownerID, roleID, "ledger") + + summaries := listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED, "", 100) + require.Len(t, summaries, 2) + identifiers := make([]string, 0, 2) + for _, summary := range summaries { + identifiers = append(identifiers, summary.GetInstallation().GetTargetId()) + require.Equal(t, gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, summary.GetHealth()) + } + require.ElementsMatch(t, + []string{audit.GetTargetId(), ledger.GetTargetId()}, identifiers) +} + +// UNSPECIFIED returns every status, so a caller that wants only live installs must +// ask for ACTIVE — the default must not silently narrow either. +func TestListInstallations_FiltersByStatus(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + ledger := installSolution(t, orgID, ownerID, roleID, "ledger") + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, _, err := testStore.UninstallSolution(ctx, orgID, audit.GetId()) + return err + })) + + active := listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, "", 100) + require.Len(t, active, 1) + require.Equal(t, ledger.GetId(), active[0].GetInstallation().GetId()) + + revoked := listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_REVOKED, "", 100) + require.Len(t, revoked, 1) + require.Equal(t, audit.GetId(), revoked[0].GetInstallation().GetId()) + + require.Len(t, listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED, "", 100), 2) +} + +// The keyset cursor walks every row exactly once. It is `id` rather than +// target_id because only `id` is unique across statuses. +func TestListInstallations_PagesWithoutRepeatingOrSkipping(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + for _, identifier := range []string{"one", "two", "three", "four"} { + installSolution(t, orgID, ownerID, roleID, identifier) + } + + seen := map[string]int{} + token := "" + for page := 0; page < 10; page++ { + summaries := listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED, token, 2) + if len(summaries) == 0 { + break + } + for _, summary := range summaries { + seen[summary.GetInstallation().GetTargetId()]++ + } + token = summaries[len(summaries)-1].GetInstallation().GetId() + if len(summaries) < 2 { + break + } + } + require.Len(t, seen, 4) + for identifier, count := range seen { + require.Equal(t, 1, count, "%s appeared %d times", identifier, count) + } +} + +func TestListInstallations_IsTenantIsolated(t *testing.T) { + orgA, ownerA, roleA := entitlementOrg(t) + auditInA := installSolution(t, orgA, ownerA, roleA, "audit") + orgB, ownerB, roleB := entitlementOrg(t) + installSolution(t, orgB, ownerB, roleB, "ledger") + + a := listInstallations(t, orgA, gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED, "", 100) + require.Len(t, a, 1) + require.Equal(t, auditInA.GetId(), a[0].GetInstallation().GetId()) +} + +func TestListInstallations_RejectsAForeignCursor(t *testing.T) { + orgID, _, _ := entitlementOrg(t) + err := testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, e := testStore.ListInstallations(ctx, orgID, gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED, "nope", 10) + return e + }) + require.Error(t, err) + require.Contains(t, err.Error(), "page_token") +} + +// ============================================================================ +// Health parity: the listing's one-statement health must be GetInstallation's +// ============================================================================ + +// listedHealth is the health the shared listing reports for one installation. +func listedHealth(t *testing.T, orgID, installationID string) gen.InstallationHealth { + t.Helper() + for _, summary := range listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_UNSPECIFIED, "", 100) { + if summary.GetInstallation().GetId() == installationID { + return summary.GetHealth() + } + } + t.Fatalf("installation %s not listed", installationID) + return gen.InstallationHealth_INSTALLATION_HEALTH_UNSPECIFIED +} + +// controlPlaneExec runs one statement as the control plane, for states no tenant +// write path produces directly (an expiry that has already passed, a role +// demoted behind the API's back). +// +// It goes through the store's own exported helper rather than fishing the +// transaction out of the context. The context key is internal to pkg/infra by +// design — only the code that opens a transaction may say what authority it +// carries — so a plain-string lookup here is not refused, it reads nil. +func controlPlaneExec(t *testing.T, sql string, args ...any) { + t.Helper() + require.NoError(t, testStore.ExecAsControlPlane(testCtx, sql, args...)) +} + +// The listing resolves health for every row inside one statement; GetInstallation +// resolves it for one row in several. They are two implementations of one rule, +// so every state is checked against both, in the precedence the rule defines. +func TestInstallationListingHealthMatchesGetInstallation(t *testing.T) { + cases := []struct { + name string + want gen.InstallationHealth + setup func(t *testing.T, orgID, ownerID, roleID, rootPath string, installation *gen.Installation) + }{ + { + name: "healthy", + want: gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, + }, + { + name: "agent revoked, which outranks the grant it also loses", + want: gen.InstallationHealth_INSTALLATION_HEALTH_AGENT_REVOKED, + setup: func(t *testing.T, orgID, _, _, _ string, installation *gen.Installation) { + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, _, err := testStore.UninstallSolution(ctx, orgID, installation.GetId()) + return err + })) + }, + }, + { + name: "agent disabled", + want: gen.InstallationHealth_INSTALLATION_HEALTH_AGENT_DISABLED, + setup: func(t *testing.T, orgID, _, _, _ string, installation *gen.Installation) { + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, err := testStore.DisableAgentPrincipal(ctx, installation.GetAgentPrincipalId(), "parity") + return err + })) + }, + }, + { + name: "no current admin among owner and co-owners", + want: gen.InstallationHealth_INSTALLATION_HEALTH_NO_ELIGIBLE_OWNER, + setup: func(t *testing.T, orgID, ownerID, _, _ string, _ *gen.Installation) { + controlPlaneExec(t, `UPDATE organization_members SET role = 'member' WHERE org_id = $1 AND user_id = $2`, orgID, ownerID) + }, + }, + { + name: "a co-owner who is still admin keeps it healthy", + want: gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, + setup: func(t *testing.T, orgID, ownerID, _, _ string, installation *gen.Installation) { + successor := seedUser(t) + seedHumanPrincipal(t, successor, "Successor Admin") + require.NoError(t, testStore.As(business.Identity{OrgID: orgID}).AddOrgMember(testCtx, successor, "admin")) + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + _, err := testStore.TransferInstallationOwnership(ctx, orgID, installation.GetId(), ownerID, []string{successor}) + return err + })) + controlPlaneExec(t, `UPDATE organization_members SET role = 'member' WHERE org_id = $1 AND user_id = $2`, orgID, ownerID) + }, + }, + { + name: "standing grant revoked", + want: gen.InstallationHealth_INSTALLATION_HEALTH_STANDING_GRANT_MISSING, + setup: func(t *testing.T, orgID, _, roleID, rootPath string, installation *gen.Installation) { + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + return testStore.RevokeScope(ctx, orgID, installation.GetAgentPrincipalId(), + gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, rootPath, roleID) + })) + }, + }, + { + name: "standing grant expired", + want: gen.InstallationHealth_INSTALLATION_HEALTH_STANDING_GRANT_MISSING, + setup: func(t *testing.T, orgID, _, _, _ string, installation *gen.Installation) { + controlPlaneExec(t, `UPDATE scope_grants SET expires_at = now() - interval '1 hour' WHERE org_id = $1 AND subject_id = $2`, + orgID, installation.GetAgentPrincipalId()) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + orgID, ownerID, roleID, installation, rootPath := installFixture(t, "doc", "write") + if tc.setup != nil { + tc.setup(t, orgID, ownerID, roleID, rootPath, installation) + } + point := getInstallationHealth(t, orgID, installation.GetId()) + require.Equal(t, tc.want, point, "GetInstallation") + require.Equal(t, point, listedHealth(t, orgID, installation.GetId()), + "the listing must answer exactly what GetInstallation answers") + }) + } +} + +// A grant that expires narrows the projection with no write at all — nothing +// calls RevokeScope, nothing bumps anything. That is why the entitlement answer is +// read on every request rather than cached on a revision advanced by grant writes: +// such a revision would not move here, and a cached menu would outlive the grant. +func TestListSolutionEntitlements_NarrowsWhenAGrantExpiresWithoutAnyWrite(t *testing.T) { + orgID, ownerID, roleID := entitlementOrg(t) + audit := installSolution(t, orgID, ownerID, roleID, "audit") + + member := seedUser(t) + seedHumanPrincipal(t, member, "Member") + seedOrgMember(t, orgID, member) + team := seedTeamWith(t, orgID, member) + grantAtNode(t, orgID, team, roleID, audit) + require.Equal(t, []string{audit.GetTargetId()}, entitledIdentifiers(t, orgID, member)) + + // Time passes past the grant's expiry. No application write occurs. + controlPlaneExec(t, `UPDATE scope_grants SET expires_at = now() - interval '1 second' WHERE org_id = $1 AND subject_id = $2`, + orgID, team) + require.Empty(t, entitledIdentifiers(t, orgID, member)) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_generation_history.go b/module/services/accounts/code/pkg/infra/postgres_solution_generation_history.go new file mode 100644 index 000000000..f9142c407 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_generation_history.go @@ -0,0 +1,91 @@ +package infra + +import ( + "context" + "time" + + "accounts/pkg/business" +) + +// solution_generation_history is a control-plane-owned platform relation, and the +// only one in this service the control plane may INSERT but not UPDATE: a +// decision is a fact about the past, and a trail whose rows can be edited is not +// a trail. Every method assumes a WithControlPlane transaction. + +const solutionGenerationHistoryColumns = `binding_id, target_id::text, generation, digest, + decision, reason, release, registry_revision, decided_at` + +// RecordSolutionGenerationDecision appends one decision. +// +// ON CONFLICT DO NOTHING against the unique index is what makes a polling +// reconciler safe: re-reading an unchanged document decides `current` at the same +// generation and digest every pass, and appending that per pass would turn a +// thirty-second poll into the event volume this trail exists to make readable. A +// genuinely new decision about the same generation differs in digest (a rewrite) +// or in decision (refused, then applied), so it still records. +func (s *PostgresStore) RecordSolutionGenerationDecision( + ctx context.Context, decision *business.SolutionGenerationDecision, +) error { + var targetID, reason, release *string + if decision.TargetID != "" { + targetID = &decision.TargetID + } + if decision.Reason != "" { + reason = &decision.Reason + } + if decision.Release != "" { + release = &decision.Release + } + decidedAt := decision.DecidedAt + if decidedAt.IsZero() { + decidedAt = time.Now().UTC() + } + _, err := s.getQueryExecutor(ctx).Exec(ctx, ` + INSERT INTO public.solution_generation_history + (binding_id, target_id, generation, digest, decision, reason, release, + registry_revision, decided_at) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (binding_id, generation, digest, decision) DO NOTHING`, + decision.BindingID, targetID, int64(decision.Generation), decision.Digest, + decision.Decision, reason, release, decision.RegistryRevision, decidedAt) + return err +} + +// ListSolutionGenerationHistory returns one component's decisions, newest first. +func (s *PostgresStore) ListSolutionGenerationHistory( + ctx context.Context, bindingID string, limit int, +) ([]*business.SolutionGenerationDecision, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.getQueryExecutor(ctx).Query(ctx, + `SELECT `+solutionGenerationHistoryColumns+` + FROM public.solution_generation_history + WHERE binding_id = $1 + ORDER BY decided_at DESC, generation DESC + LIMIT $2`, bindingID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + history := []*business.SolutionGenerationDecision{} + for rows.Next() { + var ( + decision business.SolutionGenerationDecision + targetID, reason *string + release *string + generation int64 + ) + if err := rows.Scan(&decision.BindingID, &targetID, &generation, &decision.Digest, + &decision.Decision, &reason, &release, &decision.RegistryRevision, + &decision.DecidedAt); err != nil { + return nil, err + } + decision.Generation = uint64(generation) + decision.TargetID = derefString(targetID) + decision.Reason = derefString(reason) + decision.Release = derefString(release) + history = append(history, &decision) + } + return history, rows.Err() +} diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go new file mode 100644 index 000000000..d4c2a7750 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings.go @@ -0,0 +1,232 @@ +package infra + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + + "accounts/pkg/business" +) + +// solution_host_bindings is a control-plane-owned platform relation (no tenant +// column, no RLS): only app_control_plane may touch it. Every method assumes the +// caller opened a WithControlPlane transaction, which getQueryExecutor picks up +// from ctx. + +const solutionHostBindingColumns = `binding_id, host_coordinate, host_component, + desired_generation, desired_digest, desired_document, desired_seen_at, + applied_generation, applied_digest, applied_document, applied_removed, applied_routes, + applied_solution_id, applied_release, applied_domain, applied_at, + pending_reason, pending_since, updated_at` + +func scanSolutionHostBinding(row pgx.Row) (*business.SolutionHostBindingRecord, error) { + var ( + record business.SolutionHostBindingRecord + desiredGeneration *int64 + desiredDigest *string + desiredDocument *string + desiredSeenAt *time.Time + appliedGeneration *int64 + appliedDigest *string + appliedDocument *string + appliedRemoved bool + appliedRoutes []string + appliedSolutionID *string + appliedRelease *string + appliedDomain *string + appliedAt *time.Time + pendingReason *string + pendingSince *time.Time + ) + if err := row.Scan( + &record.BindingID, &record.HostCoordinate, &record.HostComponent, + &desiredGeneration, &desiredDigest, &desiredDocument, &desiredSeenAt, + &appliedGeneration, &appliedDigest, &appliedDocument, &appliedRemoved, &appliedRoutes, + &appliedSolutionID, &appliedRelease, &appliedDomain, &appliedAt, + &pendingReason, &pendingSince, &record.UpdatedAt, + ); err != nil { + return nil, err + } + // The whole-or-absent CHECK constraints guarantee a group's columns are all + // set or all null, so the generation column alone decides whether it is + // present. + if desiredGeneration != nil { + record.Desired = &business.SolutionHostBindingGeneration{ + Generation: uint64(*desiredGeneration), + Digest: *desiredDigest, + Document: *desiredDocument, + At: *desiredSeenAt, + } + } + if appliedGeneration != nil { + record.Applied = &business.SolutionHostBindingApplied{ + SolutionHostBindingGeneration: business.SolutionHostBindingGeneration{ + Generation: uint64(*appliedGeneration), + Digest: *appliedDigest, + Document: *appliedDocument, + At: *appliedAt, + }, + Removed: appliedRemoved, + Routes: appliedRoutes, + SolutionID: derefString(appliedSolutionID), + Release: derefString(appliedRelease), + Domain: derefString(appliedDomain), + } + } + record.PendingReason = derefString(pendingReason) + record.PendingSince = pendingSince + return &record, nil +} + +// GetSolutionHostBindingForUpdate locks one binding, then reads its record, +// returning nil when nothing has been delivered for it. Both locks hold for the +// caller's transaction, which is what serializes two replicas applying the same +// generation. +// +// TWO locks, because a row lock cannot lock a row that does not exist — and the +// FIRST delivery for a binding is precisely the pass that has none. Without the +// advisory lock the sequence is: +// +// pass A reads nil and builds a record with no applied state; +// pass B reads nil, inserts, applies, and writes applied_* ; +// pass A upserts its stale record, and ON CONFLICT DO UPDATE sets every +// applied_* column to the NULLs it is carrying. +// +// The erasure is then invisible: `solution_host_bindings_applied_whole` permits +// seven NULLs — that is a legally absent group — so nothing refuses it, and a +// binding that IS serving reads as one this host never applied. Every reader of +// applied state sees no presence, and the reconciler re-applies a generation it +// had already admitted. +// +// Keyed on the binding and taken here rather than in each writer, because every +// writer of this row reaches it through this function and a lock one writer +// forgets serializes nothing. Two bindings whose ids collide in the hash +// serialize against each other, which costs throughput and nothing else. +// +// Both locks are transaction-scoped, so like `FOR UPDATE` this serializes +// nothing at all if a caller reaches it outside a `WithControlPlane` +// transaction — the assumption stated at the top of this file. +func (s *PostgresStore) GetSolutionHostBindingForUpdate( + ctx context.Context, bindingID string, +) (*business.SolutionHostBindingRecord, error) { + q := s.getQueryExecutor(ctx) + if _, err := q.Exec(ctx, + `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, bindingID); err != nil { + return nil, err + } + record, err := scanSolutionHostBinding(q.QueryRow(ctx, + `SELECT `+solutionHostBindingColumns+` + FROM public.solution_host_bindings WHERE binding_id = $1 FOR UPDATE`, bindingID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return record, nil +} + +// SaveSolutionHostBinding writes the whole record. Every column is written on +// every save, so a generation that clears a pending reason clears it in the same +// statement that records the apply. +func (s *PostgresStore) SaveSolutionHostBinding( + ctx context.Context, record *business.SolutionHostBindingRecord, +) error { + var ( + desiredGeneration *int64 + desiredDigest *string + desiredDocument *string + desiredSeenAt *time.Time + appliedGeneration *int64 + appliedDigest *string + appliedDocument *string + appliedSolutionID *string + appliedRelease *string + appliedDomain *string + appliedAt *time.Time + pendingReason *string + ) + appliedRoutes := []string{} + appliedRemoved := false + if desired := record.Desired; desired != nil { + generation := int64(desired.Generation) + desiredGeneration, desiredDigest = &generation, &desired.Digest + desiredDocument, desiredSeenAt = &desired.Document, &desired.At + } + if applied := record.Applied; applied != nil { + generation := int64(applied.Generation) + appliedGeneration, appliedDigest = &generation, &applied.Digest + appliedDocument, appliedAt = &applied.Document, &applied.At + appliedSolutionID, appliedRelease = &applied.SolutionID, &applied.Release + appliedDomain = &applied.Domain + appliedRemoved = applied.Removed + if applied.Routes != nil { + appliedRoutes = applied.Routes + } + } + if record.PendingReason != "" { + pendingReason = &record.PendingReason + } + _, err := s.getQueryExecutor(ctx).Exec(ctx, ` + INSERT INTO public.solution_host_bindings ( + binding_id, host_coordinate, host_component, + desired_generation, desired_digest, desired_document, desired_seen_at, + applied_generation, applied_digest, applied_document, applied_removed, applied_routes, + applied_solution_id, applied_release, applied_domain, applied_at, + pending_reason, pending_since, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (binding_id) DO UPDATE SET + host_coordinate = EXCLUDED.host_coordinate, + host_component = EXCLUDED.host_component, + desired_generation = EXCLUDED.desired_generation, + desired_digest = EXCLUDED.desired_digest, + desired_document = EXCLUDED.desired_document, + desired_seen_at = EXCLUDED.desired_seen_at, + applied_generation = EXCLUDED.applied_generation, + applied_digest = EXCLUDED.applied_digest, + applied_document = EXCLUDED.applied_document, + applied_removed = EXCLUDED.applied_removed, + applied_routes = EXCLUDED.applied_routes, + applied_solution_id = EXCLUDED.applied_solution_id, + applied_release = EXCLUDED.applied_release, + applied_domain = EXCLUDED.applied_domain, + applied_at = EXCLUDED.applied_at, + pending_reason = EXCLUDED.pending_reason, + pending_since = EXCLUDED.pending_since, + updated_at = EXCLUDED.updated_at`, + record.BindingID, record.HostCoordinate, record.HostComponent, + desiredGeneration, desiredDigest, desiredDocument, desiredSeenAt, + appliedGeneration, appliedDigest, appliedDocument, appliedRemoved, appliedRoutes, + appliedSolutionID, appliedRelease, appliedDomain, appliedAt, + pendingReason, record.PendingSince, record.UpdatedAt) + return err +} + +// ListSolutionHostBindings returns every binding record ordered by binding ID. +// The reconciler reads the whole set on every pass: a host holds tens of +// bindings at most, and core judges the desired set as a whole. +func (s *PostgresStore) ListSolutionHostBindings( + ctx context.Context, +) ([]*business.SolutionHostBindingRecord, error) { + rows, err := s.getQueryExecutor(ctx).Query(ctx, + `SELECT `+solutionHostBindingColumns+` + FROM public.solution_host_bindings ORDER BY binding_id`) + if err != nil { + return nil, err + } + defer rows.Close() + records := []*business.SolutionHostBindingRecord{} + for rows.Next() { + record, err := scanSolutionHostBinding(rows) + if err != nil { + return nil, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, err + } + return records, nil +} diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go new file mode 100644 index 000000000..b5ddb4784 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_host_bindings_first_insert_test.go @@ -0,0 +1,116 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/stretchr/testify/require" +) + +// Two reconcile passes meeting on a binding that has NEVER been delivered. +// +// `GetSolutionHostBindingForUpdate` takes `FOR UPDATE`, and its own comment says +// that is what serializes two replicas applying the same generation. It is not, +// on the first delivery: there is no row, so `FOR UPDATE` locks nothing. Both +// passes read nil, both build a record carrying no applied state, and +// `SaveSolutionHostBinding` writes every column on every save — so whichever +// pass commits second sets `applied_*` to the NULLs it is carrying and erases +// what the other one applied. +// +// Nothing refuses that. `solution_host_bindings_applied_whole` is satisfied by +// seven NULLs, because an absent applied group is legal. The visible result is a +// binding that is serving and reads as never applied: every reader of applied +// state sees no presence and the reconciler re-applies a generation it had +// already admitted. +// +// So this drives the two passes against each other through the store methods the +// reconcile pass itself uses, on the real schema: +// +// 1. pass A opens a control-plane transaction and takes the lock on a binding +// with no row; +// 2. pass B does the same, and must BLOCK — it cannot be allowed to read nil +// while A holds the binding; +// 3. A writes desired-only state and commits; +// 4. B unblocks, and must now SEE A's row rather than nil, so the record it +// saves is A's record plus its own change — not a fresh one that erases it. +// +// Step 2 is the assertion that fails without the advisory lock: B returns +// immediately with nil. +func TestSolutionHostBindingFirstDeliverySerializesTwoPasses(t *testing.T) { + binding := uniqueAlias("acme.first-insert") + now := time.Now().UTC() + + // Pass A: hold the binding, do not commit. + held := make(chan struct{}) + release := make(chan struct{}) + finished := make(chan error, 1) + go func() { + finished <- testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + record, err := testStore.GetSolutionHostBindingForUpdate(ctx, binding) + if err != nil { + return err + } + if record != nil { + t.Errorf("fixture: binding %q already has a record", binding) + } + close(held) + <-release + return testStore.SaveSolutionHostBinding(ctx, &business.SolutionHostBindingRecord{ + BindingID: binding, + HostCoordinate: "acme.example/host", + HostComponent: "accounts", + Desired: &business.SolutionHostBindingGeneration{ + Generation: 1, + Digest: fixtureDigest(binding + ":1"), + Document: "{}", + At: now, + }, + UpdatedAt: now, + }) + }) + }() + <-held + + // Pass B: the same binding, concurrently. It must not get past the lock. + type read struct { + record *business.SolutionHostBindingRecord + err error + } + second := make(chan read, 1) + secondCtx, cancelSecond := context.WithTimeout(context.Background(), 60*time.Second) + defer cancelSecond() + go func() { + var out read + out.err = testStore.WithControlPlane(secondCtx, func(ctx context.Context) error { + record, err := testStore.GetSolutionHostBindingForUpdate(ctx, binding) + out.record = record + return err + }) + second <- out + }() + + // Either B waits on a lock — in which case it cannot return at all while A's + // transaction is open — or it takes no lock and returns as soon as the read + // finds nothing. Three seconds separates those with a wide margin. + select { + case out := <-second: + require.Failf(t, "the second pass did not wait for the first", + "it read the binding while another pass held it (record=%v, err=%v), so both passes build a record with no applied state and the second erases the first", out.record, out.err) + case <-time.After(3 * time.Second): + } + + close(release) + require.NoError(t, <-finished, "the first pass must commit its desired state") + + out := <-second + require.NoError(t, out.err) + require.NotNil(t, out.record, + "after the first pass committed, the second must read ITS row: reading nil here is the erasure, because the record this pass then saves carries no applied state and overwrites every applied column") + require.NotNil(t, out.record.Desired) + require.Equal(t, uint64(1), out.record.Desired.Generation) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_registry.go b/module/services/accounts/code/pkg/infra/postgres_solution_registry.go index 98bdf6165..a42cda9ce 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_registry.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_registry.go @@ -3,6 +3,7 @@ package infra import ( "context" "errors" + "strings" "time" "github.com/jackc/pgx/v5" @@ -15,9 +16,11 @@ import ( // therefore assume the caller opened a WithControlPlane transaction, which // getQueryExecutor picks up from ctx. -const solutionRegistrationColumns = `solution_id, publisher, revision, runtime_boundary, tombstoned_at, - frontend_revision, frontend_manifest, frontend_contract_version, frontend_lease_expires_at, - backend_revision, backend_upstream, backend_service_alias, backend_contract_version, backend_lease_expires_at, +const solutionRegistrationColumns = `solution_id, publisher, revision, tombstoned_at, + frontend_revision, frontend_manifest, frontend_contract_version, + backend_revision, backend_upstream, backend_service_alias, backend_contract_version, + declared_binding_id, declared_generation, declared_release, declared_target_id::text, + declared_kind, updated_at` func scanSolutionRegistration(row pgx.Row) (*business.SolutionRegistration, error) { @@ -27,17 +30,22 @@ func scanSolutionRegistration(row pgx.Row) (*business.SolutionRegistration, erro frontRevision *int64 frontManifest *string frontContract *string - frontLease *time.Time backendRevision *int64 backendUpstream *string backendAlias *string backendContract *string - backendLease *time.Time + declaredBinding *string + declaredTarget *string + declaredGen *int64 + declaredRelease *string + declaredKind *string ) if err := row.Scan( - &record.SolutionID, &record.Publisher, &record.Revision, &record.RuntimeBoundary, &tombstonedAt, - &frontRevision, &frontManifest, &frontContract, &frontLease, - &backendRevision, &backendUpstream, &backendAlias, &backendContract, &backendLease, + &record.SolutionID, &record.Publisher, &record.Revision, &tombstonedAt, + &frontRevision, &frontManifest, &frontContract, + &backendRevision, &backendUpstream, &backendAlias, &backendContract, + &declaredBinding, &declaredGen, &declaredRelease, &declaredTarget, + &declaredKind, &record.UpdatedAt, ); err != nil { return nil, err @@ -50,7 +58,6 @@ func scanSolutionRegistration(row pgx.Row) (*business.SolutionRegistration, erro Revision: *frontRevision, Manifest: *frontManifest, ContractVersion: derefString(frontContract), - LeaseExpiresAt: *frontLease, } } if backendRevision != nil { @@ -59,7 +66,17 @@ func scanSolutionRegistration(row pgx.Row) (*business.SolutionRegistration, erro Upstream: *backendUpstream, ServiceAlias: *backendAlias, ContractVersion: derefString(backendContract), - LeaseExpiresAt: *backendLease, + } + } + // The cold-cutover schema requires the declared binding, generation, release, + // target and kind as one complete declaration. + if declaredBinding != nil { + record.Declared = &business.SolutionDeclaredBinding{ + BindingID: *declaredBinding, + TargetID: derefString(declaredTarget), + Generation: uint64(*declaredGen), + Release: derefString(declaredRelease), + Kind: business.SolutionDeclaredKind(derefString(declaredKind)), } } return &record, nil @@ -108,42 +125,53 @@ func (s *PostgresStore) NextSolutionRegistryRevision(ctx context.Context) (int64 // written on every save, so tombstoning — which passes a record with no halves // — clears the endpoints in the same statement that records the deletion. // -// runtime_boundary is the one column this statement will not write (issue -// #1015). It is absent from the INSERT, so a new row takes the column's -// gen_random_uuid() default, and absent from the ON CONFLICT ... DO UPDATE, so -// an existing row keeps what it was given — through a replacement half, a -// tombstone and a reactivation alike. RETURNING reports whichever of the two -// happened, and the caller's record is corrected from it: the database is the -// only thing that can answer what a solution's boundary is, and a value -// invented anywhere above here is discarded rather than stored. +// NOTHING ABOVE HERE MAY CHOOSE A RUNTIME BOUNDARY, and migration 28 is what +// makes that structural rather than a property of this statement's column list. +// The boundary used to be derived from a stored per-registration seed this +// statement deliberately omitted from both the INSERT and the ON CONFLICT DO +// UPDATE; it is now derived from `declared_binding_id`, which this statement DOES +// write — but only from the delivered declaration, never from a registrant's +// request. A boundary a caller could name would let one solution mint for +// another's and read, answer and recover its runs. func (s *PostgresStore) SaveSolutionRegistration(ctx context.Context, record *business.SolutionRegistration) error { var ( frontRevision *int64 frontManifest *string frontContract *string - frontLease *time.Time backendRevision *int64 backendUpstream *string backendAlias *string backendContract *string - backendLease *time.Time + declaredBinding *string + declaredTarget string + declaredGen *int64 + declaredRelease *string + declaredKind *string ) + if declared := record.Declared; declared != nil { + generation := int64(declared.Generation) + declaredBinding, declaredGen, declaredRelease = &declared.BindingID, &generation, &declared.Release + declaredTarget = declared.TargetID + kind := string(declared.Kind) + declaredKind = &kind + } if half := record.Frontend; half != nil { frontRevision, frontManifest = &half.Revision, &half.Manifest - frontContract, frontLease = &half.ContractVersion, &half.LeaseExpiresAt + frontContract = &half.ContractVersion } if half := record.Backend; half != nil { backendRevision, backendUpstream = &half.Revision, &half.Upstream - backendAlias, backendContract, backendLease = &half.ServiceAlias, &half.ContractVersion, &half.LeaseExpiresAt + backendAlias, backendContract = &half.ServiceAlias, &half.ContractVersion } - var boundary string - err := s.getQueryExecutor(ctx).QueryRow(ctx, ` + _, err := s.getQueryExecutor(ctx).Exec(ctx, ` INSERT INTO public.solution_registrations ( solution_id, publisher, revision, tombstoned_at, - frontend_revision, frontend_manifest, frontend_contract_version, frontend_lease_expires_at, - backend_revision, backend_upstream, backend_service_alias, backend_contract_version, backend_lease_expires_at, + frontend_revision, frontend_manifest, frontend_contract_version, + backend_revision, backend_upstream, backend_service_alias, backend_contract_version, + declared_binding_id, declared_generation, declared_release, declared_target_id, + declared_kind, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NULLIF($15, '')::uuid, $16, $17) ON CONFLICT (solution_id) DO UPDATE SET publisher = EXCLUDED.publisher, revision = EXCLUDED.revision, @@ -151,25 +179,68 @@ func (s *PostgresStore) SaveSolutionRegistration(ctx context.Context, record *bu frontend_revision = EXCLUDED.frontend_revision, frontend_manifest = EXCLUDED.frontend_manifest, frontend_contract_version = EXCLUDED.frontend_contract_version, - frontend_lease_expires_at = EXCLUDED.frontend_lease_expires_at, backend_revision = EXCLUDED.backend_revision, backend_upstream = EXCLUDED.backend_upstream, backend_service_alias = EXCLUDED.backend_service_alias, backend_contract_version = EXCLUDED.backend_contract_version, - backend_lease_expires_at = EXCLUDED.backend_lease_expires_at, - updated_at = EXCLUDED.updated_at - RETURNING runtime_boundary`, + declared_binding_id = EXCLUDED.declared_binding_id, + declared_generation = EXCLUDED.declared_generation, + declared_release = EXCLUDED.declared_release, + declared_target_id = EXCLUDED.declared_target_id, + declared_kind = EXCLUDED.declared_kind, + updated_at = EXCLUDED.updated_at`, record.SolutionID, record.Publisher, record.Revision, record.TombstonedAt, - frontRevision, frontManifest, frontContract, frontLease, - backendRevision, backendUpstream, backendAlias, backendContract, backendLease, - record.UpdatedAt).Scan(&boundary) + frontRevision, frontManifest, frontContract, + backendRevision, backendUpstream, backendAlias, backendContract, + declaredBinding, declaredGen, declaredRelease, declaredTarget, + declaredKind, + record.UpdatedAt) + return err +} + +// ListSolutionRegistrations returns the registry snapshot ordered by solution +// id, plus the highest revision in the whole registry. The revision is computed +// over every row including tombstones, so a deregistration still advances what +// consumers converge on. +func (s *PostgresStore) ListSolutionRegistrations( + ctx context.Context, includeTombstoned bool, +) ([]*business.SolutionRegistration, int64, error) { + q := s.getQueryExecutor(ctx) + rows, err := q.Query(ctx, + `SELECT `+solutionRegistrationColumns+` + FROM public.solution_registrations + WHERE $1 OR tombstoned_at IS NULL + ORDER BY solution_id`, includeTombstoned) if err != nil { - return err + return nil, 0, err + } + defer rows.Close() + records := []*business.SolutionRegistration{} + for rows.Next() { + record, err := scanSolutionRegistration(rows) + if err != nil { + return nil, 0, err + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + var registryRevision int64 + if err := q.QueryRow(ctx, + `SELECT COALESCE(MAX(revision), 0) FROM public.solution_registrations`).Scan(®istryRevision); err != nil { + return nil, 0, err } - record.RuntimeBoundary = boundary - return nil + return records, registryRevision, nil } +// The boundary SEED READS, restored from main's #1017. They were lost when this +// branch's side of the file won the merge, and the loss was SILENT: the mint +// obtains this interface with a discarded-error type assertion +// (`config.Authority.(business.SolutionRuntimeBoundarySeedStore)` in +// work_context_rpcs.go), so a store that does not implement it leaves the field +// nil and every boundary lookup dies at runtime with the build green. The +// compile-time assertion below is what makes that impossible to repeat. // SolutionRuntimeBoundarySeed returns one registered solution's boundary seed, // the publisher of record, and whether its backend half is currently serving. // @@ -183,20 +254,50 @@ func (s *PostgresStore) SaveSolutionRegistration(ctx context.Context, record *bu // "Serving" is the backend half alone, not the record's derived status: the // backend is the half that mints, and a solution whose page has not registered // yet is still entitled to a boundary for the calls its own backend makes. +// missingBackendHalf names which of the backend half's two columns is absent, +// for a refusal that says what it read rather than only "not serving". The +// schema's whole-or-absent CHECK means they move together, so one message +// covers both; naming them is what makes an operator's next step obvious. +func missingBackendHalf(revision *int64, upstream *string) string { + switch { + case revision == nil && upstream == nil: + return "backend_revision and backend_upstream" + case revision == nil: + return "backend_revision" + case upstream == nil || strings.TrimSpace(*upstream) == "": + return "backend_upstream" + } + return "" +} + func (s *PostgresStore) SolutionRuntimeBoundarySeed( ctx context.Context, solutionID string, ) (business.SolutionBoundarySeed, error) { var ( - seed string - publisher string - tombstonedAt *time.Time - backendLease *time.Time + bindingID *string + publisher string + tombstonedAt *time.Time + backendRevision *int64 + backendUpstream *string ) + // Read the DECLARED row, on every mint. This selected + // `runtime_boundary, …, backend_lease_expires_at` — and migration 26 DROPS + // the lease column, so the statement did not return a stale value, it failed + // outright: every solution-scoped mint errored on `column + // backend_lease_expires_at does not exist`. Nothing caught it because the + // suites that reach this path are DSN-gated and skip. `runtime_boundary` is + // dropped in its turn by migration 28; the binding id read below is the seed. + // + // Serving is now delivered presence: a non-tombstoned declared row whose + // applied generation carries a backend half. There is no lease to renew — + // the renewal path is deleted — so revocation reaches a capability through + // THIS read, which the mint and the gateway perform per request under the + // existing 120s cache bound, never from an unbounded snapshot. err := s.WithControlPlane(ctx, func(ctx context.Context) error { return s.getQueryExecutor(ctx).QueryRow(ctx, - `SELECT runtime_boundary, publisher, tombstoned_at, backend_lease_expires_at + `SELECT declared_binding_id, publisher, tombstoned_at, backend_revision, backend_upstream FROM public.solution_registrations WHERE solution_id = $1`, solutionID). - Scan(&seed, &publisher, &tombstonedAt, &backendLease) + Scan(&bindingID, &publisher, &tombstonedAt, &backendRevision, &backendUpstream) }) if errors.Is(err, pgx.ErrNoRows) { return business.SolutionBoundarySeed{}, business.ErrSolutionRegistrationNotFound @@ -207,10 +308,17 @@ func (s *PostgresStore) SolutionRuntimeBoundarySeed( if tombstonedAt != nil { return business.SolutionBoundarySeed{}, business.ErrSolutionRegistrationTombstoned } + // A row with no declared binding is not a declared presence at all: the + // cutover's own constraint makes an undeclared row a withdrawn one. + if bindingID == nil || strings.TrimSpace(*bindingID) == "" { + return business.SolutionBoundarySeed{}, business.ErrSolutionRegistrationTombstoned + } + missing := missingBackendHalf(backendRevision, backendUpstream) return business.SolutionBoundarySeed{ - Seed: seed, - Publisher: publisher, - BackendServing: backendLease != nil && backendLease.After(time.Now().UTC()), + BindingID: *bindingID, + Publisher: publisher, + BackendServing: missing == "", + MissingBackendHalf: missing, }, nil } @@ -221,8 +329,13 @@ func (s *PostgresStore) SolutionRuntimeBoundarySeed( func (s *PostgresStore) SolutionRuntimeBoundarySeeds(ctx context.Context) ([]string, error) { var seeds []string err := s.WithControlPlane(ctx, func(ctx context.Context) error { + // Binding ids, not the `runtime_boundary` column migration 28 drops: the collision + // check refuses a caller-named task_id that is any solution's boundary, + // and a boundary is now derived from the binding. Tombstones included — + // a withdrawn solution's runs may still be executing. rows, err := s.getQueryExecutor(ctx).Query(ctx, - `SELECT runtime_boundary FROM public.solution_registrations`) + `SELECT declared_binding_id FROM public.solution_registrations + WHERE declared_binding_id IS NOT NULL`) if err != nil { return err } @@ -242,56 +355,59 @@ func (s *PostgresStore) SolutionRuntimeBoundarySeeds(ctx context.Context) ([]str return seeds, nil } -// ListSolutionRegistrations returns the registry snapshot ordered by solution -// id, plus the highest revision in the whole registry. The revision is computed -// over every row including tombstones, so a deregistration still advances what -// consumers converge on. -func (s *PostgresStore) ListSolutionRegistrations( - ctx context.Context, includeTombstoned bool, -) ([]*business.SolutionRegistration, int64, error) { - q := s.getQueryExecutor(ctx) - rows, err := q.Query(ctx, - `SELECT `+solutionRegistrationColumns+` - FROM public.solution_registrations - WHERE $1 OR tombstoned_at IS NULL - ORDER BY solution_id`, includeTombstoned) - if err != nil { - return nil, 0, err - } - defer rows.Close() - records := []*business.SolutionRegistration{} - for rows.Next() { - record, err := scanSolutionRegistration(rows) +// SaveSolutionRegistration writes the whole record. Every half column is +// written on every save, so tombstoning — which passes a record with no halves +// — clears the endpoints in the same statement that records the deletion. +// +// NOTHING ABOVE HERE MAY CHOOSE A RUNTIME BOUNDARY, and migration 28 is what +// makes that structural rather than a property of this statement's column list. +// The boundary used to be derived from a stored per-registration seed this +// statement deliberately omitted from both the INSERT and the ON CONFLICT DO +// UPDATE; it is now derived from `declared_binding_id`, which this statement DOES +// write — but only from the delivered declaration, never from a registrant's +// request. A boundary a caller could name would let one solution mint for +// another's and read, answer and recover its runs. +// LiveDeclaredSolutionBindingIDs returns the binding id of every DECLARED, +// NON-TOMBSTONED registration. It is what the host's audience vocabulary is +// derived from (issue #952). +// +// Tombstones are EXCLUDED here, which is the one difference from +// SolutionRuntimeBoundarySeeds above, which includes them. The two want different +// rows for opposite reasons: a withdrawn solution's runs may still be executing, +// so its boundary still has to be protected from a caller naming it — but its +// audience must not survive the withdrawal that took the consumer away, or a +// capability could be minted for a solution delivery has removed. +func (s *PostgresStore) LiveDeclaredSolutionBindingIDs(ctx context.Context) ([]string, error) { + var bindings []string + err := s.WithControlPlane(ctx, func(ctx context.Context) error { + rows, err := s.getQueryExecutor(ctx).Query(ctx, + `SELECT declared_binding_id FROM public.solution_registrations + WHERE declared_binding_id IS NOT NULL AND tombstoned_at IS NULL`) if err != nil { - return nil, 0, err + return err } - records = append(records, record) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - var registryRevision int64 - if err := q.QueryRow(ctx, - `SELECT COALESCE(MAX(revision), 0) FROM public.solution_registrations`).Scan(®istryRevision); err != nil { - return nil, 0, err + defer rows.Close() + for rows.Next() { + var binding string + if err := rows.Scan(&binding); err != nil { + return err + } + bindings = append(bindings, binding) + } + return rows.Err() + }) + if err != nil { + return nil, err } - return records, registryRevision, nil + return bindings, nil } -// ForceSolutionRuntimeBoundarySeedForTest writes a seed directly, bypassing -// every rule above it. It exists so the UNIQUE constraint on the column can be -// proved: nothing in the service writes that column, so there is no legitimate -// path that could ever collide, and a constraint no test can reach is a -// constraint nobody knows still exists. It is never called outside a test — -// `_ForTest` is what says so, and the control-plane call-site golden records -// the authority it reaches. -func (s *PostgresStore) ForceSolutionRuntimeBoundarySeedForTest( - ctx context.Context, solutionID, seed string, -) error { - return s.WithControlPlane(ctx, func(ctx context.Context) error { - _, err := s.getQueryExecutor(ctx).Exec(ctx, - `UPDATE public.solution_registrations SET runtime_boundary = $2 WHERE solution_id = $1`, - solutionID, seed) - return err - }) -} +// Asserted at compile time, because the only other consumer is a type assertion +// that discards its error. Without this line, deleting any method above is a +// silent downgrade rather than a build failure. +var _ business.SolutionRuntimeBoundarySeedStore = (*PostgresStore)(nil) + +// Likewise for the audience vocabulary's reader: Service resolves it with a +// discarded-error type assertion too, so without this line deleting the method +// above would leave the host refusing every mint at runtime, build green. +var _ business.DeclaredSolutionBindingStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_targets.go b/module/services/accounts/code/pkg/infra/postgres_solution_targets.go new file mode 100644 index 000000000..9f644eccf --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_targets.go @@ -0,0 +1,275 @@ +package infra + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "accounts/pkg/business" +) + +// solution_targets is a control-plane-owned platform relation (no tenant column, +// no RLS), written only by the reconcile pass. Every method assumes the caller +// opened a WithControlPlane transaction, which getQueryExecutor picks up from ctx. + +const solutionTargetColumns = `id::text, binding_id, solution_id, + opened_generation, closed_generation, opened_at, closed_at` + +func scanSolutionTarget(row pgx.Row) (*business.SolutionTarget, error) { + var ( + target business.SolutionTarget + openedGeneration int64 + closedGeneration *int64 + closedAt *time.Time + ) + if err := row.Scan(&target.ID, &target.BindingID, &target.SolutionID, + &openedGeneration, &closedGeneration, &target.OpenedAt, &closedAt); err != nil { + return nil, err + } + target.OpenedGeneration = uint64(openedGeneration) + if closedGeneration != nil { + generation := uint64(*closedGeneration) + target.ClosedGeneration = &generation + } + target.ClosedAt = closedAt + return &target, nil +} + +// GetLiveSolutionTargetForUpdate reads and row-locks the open target for a +// binding, returning nil when the binding has none — which is both "never +// present" and "withdrawn", deliberately: a withdrawn binding has no live target +// to inherit, and that is the whole point of the record. +// GetSolutionTarget resolves a target by its own identity, open or closed. It +// is the read behind an authority decision that must name a BINDING rather than +// a route alias: an installation carries a target id, the target carries the +// binding whose presence it recorded, and the closed_generation it returns is +// what makes a withdrawn period refuse rather than look live. +// +// No FOR UPDATE: this is a read on the authorization path, not a step in the +// apply transaction that mints and closes targets. +func (s *PostgresStore) GetSolutionTarget( + ctx context.Context, targetID string, +) (*business.SolutionTarget, error) { + target, err := scanSolutionTarget(s.getQueryExecutor(ctx).QueryRow(ctx, + `SELECT `+solutionTargetColumns+` + FROM public.solution_targets + WHERE id = $1`, targetID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return target, nil +} + +func (s *PostgresStore) GetLiveSolutionTargetForUpdate( + ctx context.Context, bindingID string, +) (*business.SolutionTarget, error) { + target, err := scanSolutionTarget(s.getQueryExecutor(ctx).QueryRow(ctx, + `SELECT `+solutionTargetColumns+` + FROM public.solution_targets + WHERE binding_id = $1 AND closed_generation IS NULL + FOR UPDATE`, bindingID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return target, nil +} + +// OpenSolutionTarget mints a new target. The partial unique indexes on +// (binding_id) and (solution_id) where the target is open are what make this +// safe for two replicas reconciling one pass: the second gets a unique violation +// rather than a second identity for the same presence. +func (s *PostgresStore) OpenSolutionTarget( + ctx context.Context, bindingID, solutionID string, generation uint64, now time.Time, +) (*business.SolutionTarget, error) { + return scanSolutionTarget(s.getQueryExecutor(ctx).QueryRow(ctx, ` + INSERT INTO public.solution_targets + (binding_id, solution_id, opened_generation, opened_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $4, $4) + RETURNING `+solutionTargetColumns, + bindingID, solutionID, int64(generation), now)) +} + +// RetargetSolutionTarget records that the applied generation moved this target's +// route alias. The identity does not change — that is the invariant this whole +// record exists for — so only the alias and the audit columns move. +func (s *PostgresStore) RetargetSolutionTarget( + ctx context.Context, targetID, solutionID string, now time.Time, +) error { + _, err := s.getQueryExecutor(ctx).Exec(ctx, ` + UPDATE public.solution_targets + SET solution_id = $2, updated_at = $3 + WHERE id = $1::uuid AND closed_generation IS NULL`, targetID, solutionID, now) + return err +} + +// CloseSolutionTarget ends a target at the tombstone generation that withdrew it. +// The row survives: a closed target is the evidence that an installation's +// consent ended, and without it a reused alias is indistinguishable from a +// continuous presence. +func (s *PostgresStore) CloseSolutionTarget( + ctx context.Context, targetID string, generation uint64, now time.Time, +) error { + _, err := s.getQueryExecutor(ctx).Exec(ctx, ` + UPDATE public.solution_targets + SET closed_generation = $2, closed_at = $3, updated_at = $3 + WHERE id = $1::uuid AND closed_generation IS NULL`, targetID, int64(generation), now) + return err +} + +// ListSolutionTargets returns every target, open and closed, ordered so a +// binding's periods read in sequence. +func (s *PostgresStore) ListSolutionTargets(ctx context.Context) ([]*business.SolutionTarget, error) { + rows, err := s.getQueryExecutor(ctx).Query(ctx, + `SELECT `+solutionTargetColumns+` + FROM public.solution_targets ORDER BY binding_id, opened_generation`) + if err != nil { + return nil, err + } + defer rows.Close() + targets := []*business.SolutionTarget{} + for rows.Next() { + target, err := scanSolutionTarget(rows) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + return targets, rows.Err() +} + +// RevokeInstallationsOfTarget ends every active installation of a target the +// reconcile pass has just closed, and returns the rows it flipped. +// +// It runs on the control-plane connection, in the same transaction as the close. +// That is not an optimisation: a withdrawal crosses every organisation that +// installed the target, so there is no single tenant transaction this could run +// in, and leaving it to a later sweep would mean a window in which an +// installation records consent to a presence that has ended. +// +// `installations` FORCEs row-level security and its policies admit the +// control-plane role, so this UPDATE matches rows without lifting anything — the +// relation is reached the same way every other control-plane installation write +// reaches it. +// +// The status/revoked_at consistency CHECK is satisfied by setting both, and the +// `installations_target_immutable` trigger is untouched because target_id is not +// in the SET list: the row keeps naming the identity whose consent ended, which +// is what makes the revocation attributable. +func (s *PostgresStore) RevokeInstallationsOfTarget( + ctx context.Context, targetID, reason string, now time.Time, +) ([]business.RevokedInstallation, error) { + rows, err := s.getQueryExecutor(ctx).Query(ctx, ` + UPDATE public.installations + SET status = 'revoked', revoked_at = $3, revoked_reason = $2 + WHERE target_id = $1::uuid AND status = 'active' + RETURNING id::text, org_id::text`, targetID, reason, now) + if err != nil { + return nil, err + } + defer rows.Close() + revoked := []business.RevokedInstallation{} + for rows.Next() { + var entry business.RevokedInstallation + if err := rows.Scan(&entry.InstallationID, &entry.OrgID); err != nil { + return nil, err + } + revoked = append(revoked, entry) + } + return revoked, rows.Err() +} + +// ListAvailableSolutionTargets is the catalogue: live targets whose binding's +// newest APPLIED generation is a present one. +// +// The acceptance predicate is the join itself, which is why it is one statement: +// a target is offered only when `solution_host_bindings` holds an applied +// generation for its binding that is NOT a tombstone. A binding whose newest +// DESIRED generation was refused still has its previous applied generation, so it +// stays offered at the release this host actually admitted — which is the correct +// answer and the one a diagnostic listing would get wrong by reporting the +// refused desired generation beside it. +// +// `installed` is resolved in the same statement rather than by a second query per +// row, for the reason the installation listing resolves health inline: a +// catalogue of n rows must not cost n+1 round trips. +// +// Control-plane: it reads presence state, which request traffic holds no grant +// on. The org is a parameter, never the session's — the caller is an org admin +// whose organisation the gateway projected from a verified identity. +func (s *PostgresStore) ListAvailableSolutionTargets( + ctx context.Context, query business.AvailableSolutionQuery, +) ([]*business.AvailableSolutionTarget, error) { + limit := query.Limit + if limit <= 0 { + limit = 1 + } + rows, err := s.getQueryExecutor(ctx).Query(ctx, ` + SELECT t.id::text, t.binding_id, t.solution_id, + b.applied_release, t.opened_generation, b.applied_generation, + ($1 <> '' AND EXISTS ( + SELECT 1 FROM public.installations i + WHERE i.target_id = t.id AND i.org_id = $1::uuid AND i.status = 'active' + )) + FROM public.solution_targets t + JOIN public.solution_host_bindings b ON b.binding_id = t.binding_id + WHERE t.closed_generation IS NULL + AND b.applied_generation IS NOT NULL + AND b.applied_removed = FALSE + AND ($4 = '' OR t.id::text = $4) + AND ($2 = '' OR t.id::text > $2) + ORDER BY t.id + LIMIT $3`, query.OrgID, query.Cursor, limit, query.TargetID) + if err != nil { + return nil, err + } + defer rows.Close() + available := []*business.AvailableSolutionTarget{} + for rows.Next() { + var ( + entry business.AvailableSolutionTarget + release *string + openedGeneration int64 + appliedGeneration int64 + ) + if err := rows.Scan(&entry.TargetID, &entry.BindingID, &entry.RouteAlias, + &release, &openedGeneration, &appliedGeneration, &entry.Installed); err != nil { + return nil, err + } + entry.OpenedGeneration = uint64(openedGeneration) + entry.AppliedGeneration = uint64(appliedGeneration) + entry.ReleasePublisher, entry.ReleaseName, entry.ReleaseVersion = splitReleaseIdentity(release) + available = append(available, &entry) + } + return available, rows.Err() +} + +// splitReleaseIdentity reverses core's Release.Identity() — +// `publisher/name@version` — whose publisher and name are single path segments, +// so the joined string maps back to exactly one of each. +// +// A release that does not match the shape yields three empty strings rather than +// a partial guess: a catalogue row showing half a release version is worse than +// one showing none, because only the second is visibly wrong. +func splitReleaseIdentity(identity *string) (publisher, name, version string) { + if identity == nil { + return "", "", "" + } + owner, version, found := strings.Cut(*identity, "@") + if !found { + return "", "", "" + } + publisher, name, found = strings.Cut(owner, "/") + if !found { + return "", "", "" + } + return publisher, name, version +} diff --git a/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go b/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go index 89e35cf10..120b24496 100644 --- a/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_webhooks_test.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "strings" + "sync" "testing" "time" @@ -348,12 +349,20 @@ func TestCreateAndListWebhookDeliveries(t *testing.T) { } func TestDurableAuditEmitterCreatesWebhookOutboxAtomically(t *testing.T) { + // Guarded, because the handler runs on the server's goroutine and the + // assertions run on the test's. The round trip happens to establish + // happens-before in practice, which is exactly why an unguarded version + // passes until -race or a scheduling change says otherwise. + var captureMutex sync.Mutex var receivedBody []byte var receivedDeliveryID, receivedEventID string endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedBody, _ = io.ReadAll(r.Body) + body, _ := io.ReadAll(r.Body) + captureMutex.Lock() + receivedBody = body receivedDeliveryID = r.Header.Get("X-Webhook-Delivery-ID") receivedEventID = r.Header.Get("X-Webhook-Event-ID") + captureMutex.Unlock() w.WriteHeader(http.StatusNoContent) })) t.Cleanup(endpoint.Close) @@ -396,17 +405,29 @@ func TestDurableAuditEmitterCreatesWebhookOutboxAtomically(t *testing.T) { }) // The audit record and its event commit together; fan-out is the relay's // work afterwards, which is what the emitter no longer does itself. - relayed, err := transport.RelayOnce(testCtx) - require.NoError(t, err) - require.GreaterOrEqual(t, relayed, 1) - + // Relay until THIS event has fanned out, for the same reason the delivery + // loop below exists: `relayed >= 1` is satisfied by any other test's event + // sitting ahead of this one in the shared relay. It did not cause a failure + // here — the fan-out happened to land in the first pass — which is precisely + // what makes it worth fixing now rather than when it starts failing. + // + // The subscription scopes the read, so draining other events is harmless; + // what must not happen is concluding from a count that ours was among them. var deliveries []*business.WebhookDelivery - require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { - var err error - deliveries, err = testStore.ListWebhookDeliveries(ctx, sub.ID, 10) - return err - })) - require.Len(t, deliveries, 1) + for pass := 0; pass < 50 && len(deliveries) == 0; pass++ { + relayed, relayErr := transport.RelayOnce(testCtx) + require.NoError(t, relayErr) + require.NoError(t, testStore.WithOrgTx(testCtx, orgID, func(ctx context.Context) error { + var listErr error + deliveries, listErr = testStore.ListWebhookDeliveries(ctx, sub.ID, 10) + return listErr + })) + if relayed == 0 && len(deliveries) == 0 { + break + } + } + require.Len(t, deliveries, 1, + "event %s never fanned out to subscription %s after draining the shared relay", eventID, sub.ID) require.Equal(t, eventID, deliveries[0].EventID) require.Equal(t, eventID, deliveries[0].OutboxEventID) require.Equal(t, "pending", deliveries[0].Status) @@ -434,11 +455,43 @@ func TestDurableAuditEmitterCreatesWebhookOutboxAtomically(t *testing.T) { Handler: handler, WorkerID: "outbound-webhook-integration", BatchSize: 100, }) require.NoError(t, err) - processed, _ := worker.RunOnce(testCtx) - require.Positive(t, processed) - require.Equal(t, deliveries[0].Payload, string(receivedBody)) - require.Equal(t, deliveries[0].ID, receivedDeliveryID) - require.Equal(t, eventID, receivedEventID) + // Run until THIS delivery arrives, rather than once with a count. + // + // `OutboundWebhookQueue` is shared by every test in this package, so a + // single RunOnce with BatchSize 100 claims whatever is at the head of the + // queue — and `require.Positive(processed)` is satisfied by a hundred OTHER + // tests' jobs while this one's sits behind them. That is what made this test + // fail: not contention for a row, but an assertion on a COUNT where an + // IDENTITY was meant. The same shape as asserting a suite ran because the + // exit code was zero. + // + // Bounded, so a genuinely undelivered job fails rather than hanging, and the + // failure names what was being waited for instead of reporting an empty + // string. + var delivered bool + for pass := 0; pass < 50 && !delivered; pass++ { + processed, runErr := worker.RunOnce(testCtx) + require.NoError(t, runErr) + captureMutex.Lock() + delivered = receivedDeliveryID == deliveries[0].ID + captureMutex.Unlock() + if processed == 0 && !delivered { + // Nothing left to claim and ours never came: a real failure, and + // looping further would only turn it into a timeout. + break + } + } + captureMutex.Lock() + gotBody, gotDeliveryID, gotEventID := string(receivedBody), receivedDeliveryID, receivedEventID + captureMutex.Unlock() + + require.True(t, delivered, + "delivery %s for event %s never reached the endpoint after draining the shared %s queue; "+ + "last delivery seen was %q", + deliveries[0].ID, eventID, business.OutboundWebhookQueue, gotDeliveryID) + require.Equal(t, deliveries[0].Payload, gotBody) + require.Equal(t, deliveries[0].ID, gotDeliveryID) + require.Equal(t, eventID, gotEventID) var jobState string var jobAttempts int diff --git a/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go b/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go index 467d8373d..bc0787e88 100644 --- a/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_work_context_authority_test.go @@ -253,6 +253,9 @@ func TestRevokingAgentPrincipalStalesOutstandingWorkContext(t *testing.T) { svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) require.NoError(t, svc.RevokePrincipal(testCtx, agent.ID, "delegation disabled")) after, err := testStore.ResolveWorkContextAuthority(verified, orgID, ownerID, "", nil) diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go new file mode 100644 index 000000000..69028779f --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_delivery_activation_test.go @@ -0,0 +1,288 @@ +//go:build !pure + +package infra_test + +import ( + "encoding/json" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// ACTIVATION, from the inbox the delivery endpoint writes. +// +// The defect this covers is an absence rather than a wrong answer: the endpoint +// accepted authority documents, persisted them, and NOTHING EVER READ ONE. There +// was no call site for core's Activate anywhere in this host, so a reviewed, +// signed, envelope-bound grant could be delivered and the host would behave +// exactly as if it had not been. +// +// Both halves are POSTed through the real handler and read back out of real +// Postgres, so what is under test is the host's own path from "a carrier +// arrived" to "this tuple is active" — not a hand-assembled pair of documents +// handed straight to core, which proves core. +// +// core's own fixtures are the documents, and here that is load-bearing rather +// than tidy: the authority fixture, the presence fixture and FixtureEnvelope are +// a matched tuple core ships BECAUSE getting one is fiddly — the approved build +// must be the build the presence document's workload declares, both halves must +// name the same envelope revision, and the authority must be effective from a +// generation the presence document has reached. A tuple this repository invented +// would agree with this repository's reading of those rules by construction. + +// TestDeliveredAuthorityActivatesAgainstDeliveredPresence drives the whole +// question through the host. +func TestDeliveredAuthorityActivatesAgainstDeliveredPresence(t *testing.T) { + delivery := newProductionPathDelivery(t) + + delivery.callerNamespace = fixturePresenceNamespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, signedFixture(t, "presence"))) + delivery.callerNamespace = authorityWriterNamespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, signedFixture(t, "authority"))) + + activation := fixtureActivation(t, delivery.service, solutionhost.FixtureEnvelope()) + require.NotNil(t, activation, "the reconciler exposes no activation seam, so the authority inbox is unread again") + + build := fixturePresenceBuild(t) + active, err := activation.Activate(testCtx, solutionhost.FixtureBindingID, build) + require.NoError(t, err) + require.Equal(t, fixtureAuthorityID, active.Authority) + require.Equal(t, solutionhost.FixtureBindingID, active.Binding) + require.Equal(t, build, active.Build) + require.Equal(t, uint64(4), active.Generation, "the activation names the PRESENCE generation that activated it") + require.Equal(t, uint64(solutionhost.FixtureEnvelopeRevision), active.EnvelopeRevision) + require.Equal(t, solutionhost.FixtureDomain, active.Domain) + require.Equal(t, solutionhost.FixtureCoordinate, active.Host.Coordinate, + "an activation that did not say which host it was for could be read as an activation anywhere") + + // ANOTHER BUILD ACTIVATES NOTHING. This is the acceptance case for the whole + // design: authority is held for the build that was reviewed, so shipping a + // new build does not carry the old build's authority forward. The second + // approved build in FixtureEnvelope is approved and is not the one the + // presence document runs, so the refusal is about the tuple rather than + // about the ceiling. + other := solutionhost.FixtureEnvelope().ApprovedBuilds[1] + require.NotEqual(t, build, other) + _, err = activation.Activate(testCtx, solutionhost.FixtureBindingID, other) + require.ErrorIs(t, err, solutionhost.ErrNotActivated) + + // PRESENCE ALONE GRANTS NOTHING. A binding delivered with no authority + // document over it is not a refusal of a grant — nothing was granted — and + // the two are distinct errors because an operator fixes them in different + // places. + solutionID := uniqueAlias("act") + namespace := "ns-" + solutionID + presenceOnly := productionPathBinding(t, solutionID, namespace, 1) + delivery.callerNamespace = namespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, + productionPathCarrier(t, presenceOnly))) + _, err = activation.Activate(testCtx, presenceOnly.Binding, build) + require.ErrorIs(t, err, business.ErrSolutionAuthorityNotDelivered) +} + +// NO CEILING MEANS NO ANSWER, not a permissive one. +// +// A host with no envelope delivered has decided nothing about authority, which +// is a different fact from deciding that a grant is absent — and the difference +// is the whole reason the error is its own sentinel. A zero envelope that fell +// through to core would be refused there too, by the "no envelope revision was +// named" guard, but with a message about a tuple rather than about this host's +// configuration. +func TestWithoutAnEnvelopeTheHostActivatesNothing(t *testing.T) { + delivery := newProductionPathDelivery(t) + delivery.callerNamespace = fixturePresenceNamespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, signedFixture(t, "presence"))) + delivery.callerNamespace = authorityWriterNamespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, signedFixture(t, "authority"))) + + // The zero envelope: what a host that was delivered no ceiling holds. + activation := fixtureActivation(t, delivery.service, solutionhost.Envelope{}) + _, err := activation.Activate(testCtx, solutionhost.FixtureBindingID, fixturePresenceBuild(t)) + require.ErrorIs(t, err, business.ErrSolutionAuthorityCeilingUnavailable) +} + +// fixtureActivation builds the activation seam for a host that answers for the +// FIXTURE coordinate and domain, which is what the shipped documents target. +func fixtureActivation( + t *testing.T, service *business.Service, envelope solutionhost.Envelope, +) *business.SolutionAuthorityActivation { + t.Helper() + return activationFor(t, service, solutionhost.FixtureCoordinate, envelope) +} + +func activationFor( + t *testing.T, service *business.Service, coordinate string, envelope solutionhost.Envelope, +) *business.SolutionAuthorityActivation { + t.Helper() + reconciler, err := business.NewSolutionHostBindingReconciler(service, + business.SolutionHostBindingReconcilerConfig{ + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: productionPathVerifier{signer: productionPathSigner}, + Coordinate: coordinate, + Domains: []string{solutionhost.FixtureDomain, productionPathDomain}, + DomainsBySigner: map[string][]string{productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}}, + Interval: time.Minute, + Envelope: envelope, + }) + require.NoError(t, err) + return reconciler.AuthorityActivation() +} + +// fixturePresenceBuild is the image digest core's valid presence fixture's +// workload declares — read out of the fixture rather than written down, so a +// fixture whose build moves fails here instead of silently testing a build no +// document names. +func fixturePresenceBuild(t *testing.T) solutionhost.ImageDigest { + t.Helper() + document, err := solutionhost.Parse(presenceFixtureDocument(t)) + require.NoError(t, err) + builds := document.Builds() + require.Len(t, builds, 1) + return builds[0] +} + +func presenceFixtureDocument(t *testing.T) []byte { + t.Helper() + data, err := solutionhost.FixtureDocument(solutionhost.DocumentTypePresence, "valid") + require.NoError(t, err) + return data +} + +// A WITHDRAWN AUTHORITY IS NOT REINSTATED BY RENAMING IT. +// +// This is the one part of core's generation fold the inbox cannot perform by +// itself, so the host performs it explicitly — and the reason it has to is +// specific. core keys the authority fold on the BINDING rather than on the +// authority id, precisely because an authority re-signed under a NEW id has no +// applied record of its own and would therefore read as a first generation. This +// host holds no applied authority record at all, so that fold is not available +// to it: what stands in is reading EVERY delivered authority document over the +// binding and refusing when a tombstone is among them. Nothing deletes from the +// inbox, so a withdrawal stays visible forever, which is what makes the +// substitute equivalent rather than merely similar. +// +// Core's own authority tombstone fixture is deliberately NOT used here. It +// carries the same authority id as the valid fixture at a higher generation, and +// this package's inbox is a shared, append-only table with no DELETE grant — so +// delivering it would supersede the valid document for every later run of +// TestDeliveredAuthorityActivatesAgainstDeliveredPresence. The documents here +// are this file's own, over a binding id unique to the run. +func TestAWithdrawnAuthorityIsNotReinstatedUnderANewID(t *testing.T) { + solutionID := uniqueAlias("wd") + namespace := "ns-" + solutionID + presence := productionPathBinding(t, solutionID, namespace, 1) + build := presence.Builds()[0] + + delivery := newProductionPathDelivery(t) + delivery.callerNamespace = namespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, + productionPathCarrier(t, presence))) + + // First the grant, then its withdrawal, then the SAME grant under a new + // authority id — which is the move the check exists to refuse. + granted := productionPathAuthority(t, solutionID, presence.Binding, "a", 1, build, false) + withdrawn := productionPathAuthority(t, solutionID, presence.Binding, "a", 2, build, true) + renamed := productionPathAuthority(t, solutionID, presence.Binding, "b", 1, build, false) + + activation := activationFor(t, delivery.service, productionPathCoordinate, + productionPathEnvelope(solutionID, namespace, build)) + + delivery.callerNamespace = authorityWriterNamespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, + productionPathAuthorityCarrier(t, granted))) + active, err := activation.Activate(testCtx, presence.Binding, build) + require.NoError(t, err, "the grant must activate before its withdrawal can mean anything") + require.Equal(t, granted.Authority, active.Authority) + + requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, + productionPathAuthorityCarrier(t, withdrawn))) + _, err = activation.Activate(testCtx, presence.Binding, build) + require.ErrorIs(t, err, business.ErrSolutionAuthorityWithdrawn) + + requireDelivered(t, delivery.post(t, business.SolutionDeliveryAuthority, + productionPathAuthorityCarrier(t, renamed))) + _, err = activation.Activate(testCtx, presence.Binding, build) + require.ErrorIs(t, err, business.ErrSolutionAuthorityWithdrawn, + "a withdrawal is a statement about the BINDING, so a new authority id over it reinstates nothing") +} + +// productionPathAuthority is one authority document over one binding, under the +// authority id `acme.test..`. +func productionPathAuthority( + t *testing.T, solutionID, binding, suffix string, generation uint64, + build solutionhost.ImageDigest, removed bool, +) *solutionhost.AuthorityDocument { + t.Helper() + document := &solutionhost.AuthorityDocument{ + Schema: solutionhost.SchemaAuthorityV1, + Authority: "acme.test." + solutionID + "." + suffix, + Generation: generation, + PresenceBinding: binding, + Host: solutionhost.HostTarget{Coordinate: productionPathCoordinate, Component: "saas-host"}, + OwnershipDomain: productionPathDomain, + EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, + } + if !removed { + document.ApprovedBuild = build + document.EffectiveFrom = 1 + document.Principals = []solutionhost.PrincipalAuthority{{ + Principal: "principal:operator", + Bindings: []solutionhost.AuthorityBinding{authorityBindingFor(solutionID, "ns-"+solutionID)}, + }} + } else { + // A withdrawn generation names no principal, approves no build and is + // effective from no generation. Carrying any of those would be a + // withdrawal that still says what it grants. + document.Removed = true + } + require.NoError(t, document.Validate(), "the fixture must be a valid authority document") + return document +} + +// productionPathEnvelope is the ceiling the documents above sit inside. It is +// assembled by the TEST rather than read out of a document, which is the rule: +// an envelope a document carried would be a document declaring its own ceiling. +func productionPathEnvelope( + solutionID, namespace string, build solutionhost.ImageDigest, +) solutionhost.Envelope { + return solutionhost.Envelope{ + Revision: solutionhost.FixtureEnvelopeRevision, + Grants: []solutionhost.EnvelopeGrant{{ + Principal: "principal:operator", + Binding: authorityBindingFor(solutionID, namespace), + }}, + ApprovedBuilds: []solutionhost.ImageDigest{build}, + } +} + +func authorityBindingFor(solutionID, namespace string) solutionhost.AuthorityBinding { + return solutionhost.AuthorityBinding{ + ID: "binding:" + solutionID + ":reconcile", + Revision: 1, + Audience: "https://test.acme.example/operations", + Scope: "reconcile", + Queue: "reconcile.default", + Namespace: namespace, + } +} + +// productionPathAuthorityCarrier wraps an authority document in the carrier +// delivery POSTs. SignedPayloadFor, not Marshal: the signing input is the +// canonical encoding, and AuthorityFromVerified refuses a payload that is not +// the canonical encoding of the document it decodes to. +func productionPathAuthorityCarrier(t *testing.T, document *solutionhost.AuthorityDocument) []byte { + t.Helper() + payload, err := solutionhost.SignedPayloadFor(document) + require.NoError(t, err) + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: payload, + Bundle: json.RawMessage(solutionhost.FixtureBundle), + }) + require.NoError(t, err) + return carrier +} diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go b/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go new file mode 100644 index 000000000..7a2acb694 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_delivery_carrier.go @@ -0,0 +1,176 @@ +package infra + +import ( + "context" + "errors" + "fmt" + "strings" + + "accounts/pkg/business" +) + +// Who may HAND a delivered document to this host. +// +// Separate from who signed it, and keeping the two apart is the point. The +// bundle says who produced the content; this says who delivered it. A valid +// bundle does not make every caller a legitimate carrier — otherwise anyone who +// could replay a captured carrier could re-deliver it, including a superseded +// generation whose signature is still perfectly good. +// +// The two halves are authorised differently, and the asymmetry is deliberate: +// +// - AUTHORITY has one fixed (service account, namespace) pair, because there +// is one authority writer for the whole platform. +// - PRESENCE is checked against the namespace THE DOCUMENT'S OWN WORKLOADS +// DECLARE, because a presence Job runs one per module tree in that module's +// own namespace. A fixed value would refuse every genuine carrier. +// +// A generation that declares no workload declares no namespace, and a TOMBSTONE +// is exactly that: removal is a generation, and it carries nothing. Refusing the +// empty case outright therefore made removal through this endpoint IMPOSSIBLE. +// The caller resolves such a document against the namespace this host last +// applied for that binding, which is its own recorded state rather than +// anything the arriving document asserts. +// +// Reading the presence namespace out of the signed document rather than from +// configuration is what keeps it out of a deployer's reach — the same property +// that puts the ownership domain inside the canonical bytes. And this host's +// check is the SECOND layer, not the perimeter: a manifest claiming a namespace +// other than the one its delivery tree declares is refused by the delivery +// controller's own destination list, at apply. + +// The delivery service account, for both kinds. One name rather than two: the +// renderer uses a single constant, and a second name here would be a constant +// only this host believes in — which fails with a TokenReview that SUCCEEDS on a +// genuine identity, refused for a name. +const deliveryServiceAccount = "delivery" + +// The authority half's fixed namespace. +const authorityDeliveryNamespace = "platform-authority" + +// The audience a delivery carrier's token must be minted for. The same audience +// the credential mint uses, because accounts is the service performing the +// review in both cases and a second audience is another projected token to +// mis-mount. +const deliveryTokenAudience = "accounts" + +// ErrCarrierUnauthenticated reports that the carrier's credential was reviewed +// and refused, or was absent. Terminal: the token is kubelet-projected and +// re-read per request, so a refusal is a configuration fault and a retry asks +// the same question. +var ErrCarrierUnauthenticated = errors.New("delivery carrier is not authenticated") + +// ErrCarrierNotAuthorized reports that the carrier authenticated but is not the +// writer for this kind of document. Terminal. +// +// Distinct from unauthenticated because the two are fixed in different places: a +// refused token is the caller's credential, and a wrong service account or +// namespace is the deployment's wiring. +var ErrCarrierNotAuthorized = errors.New("delivery carrier is not the authorized writer for this document kind") + +// SolutionDeliveryCarrierCheck authorises a carrier by TokenReview. +type SolutionDeliveryCarrierCheck struct { + kubernetes *KubernetesClient +} + +// NewSolutionDeliveryCarrierCheck builds the carrier authorizer. +func NewSolutionDeliveryCarrierCheck(kubernetes *KubernetesClient) *SolutionDeliveryCarrierCheck { + return &SolutionDeliveryCarrierCheck{kubernetes: kubernetes} +} + +// AuthorizeCarrier reviews the caller's token and checks the identity it +// authenticates as against the writer this document kind requires. +// +// The failure taxonomy is the contract, because each class has a different right +// response from the caller: +// +// - no token, or a token the API server REFUSED → ErrCarrierUnauthenticated, +// terminal (401). The credential is wrong; retrying asks the same question. +// - authenticated, wrong writer → ErrCarrierNotAuthorized, terminal (403). +// - the API server could not be ASKED → ErrKubernetesUnavailable, retryable +// (503). Nothing was verified, so nothing may be concluded. +// +// The third is why the Kubernetes client separates "refused" from "unreachable" +// at all: a control-plane outage reported as a refusal would look like a fleet +// of misconfigured callers, and an operator would go and check the callers. +func (c *SolutionDeliveryCarrierCheck) AuthorizeCarrier( + ctx context.Context, credential string, kind business.SolutionDeliveryKind, declaredNamespaces []string, +) error { + if c == nil || c.kubernetes == nil { + // Fail closed. A deployment that mounted the endpoint without a way to + // review carriers must not accept documents from anyone. + return fmt.Errorf("%w: this host cannot review a carrier's credential", ErrKubernetesUnavailable) + } + token := bearerToken(credential) + if token == "" { + return fmt.Errorf("%w: no bearer token was presented", ErrCarrierUnauthenticated) + } + + reviewed, err := c.kubernetes.ReviewToken(ctx, token, deliveryTokenAudience) + if err != nil { + if errors.Is(err, ErrTokenRejected) { + return fmt.Errorf("%w: %w", ErrCarrierUnauthenticated, err) + } + // Unreachable, or the host's own RBAC refused: nothing was verified. + return err + } + + if reviewed.ServiceAccount != deliveryServiceAccount { + return fmt.Errorf("%w: %s is delivered by service account %q, and the carrier authenticated as %q", + ErrCarrierNotAuthorized, kind, deliveryServiceAccount, reviewed.ServiceAccount) + } + + switch kind { + case business.SolutionDeliveryAuthority: + if reviewed.Namespace != authorityDeliveryNamespace { + return fmt.Errorf("%w: authority is delivered from namespace %q, and the carrier authenticated in %q", + ErrCarrierNotAuthorized, authorityDeliveryNamespace, reviewed.Namespace) + } + return nil + + case business.SolutionDeliveryPresence: + // The namespace comes from the signed document's own workloads, or — + // for a generation that declares none — from the namespace this host + // last APPLIED for that binding. Which of the two answered is the + // caller's to resolve; see business.presenceCarrierNamespaces. + // + // Empty means neither did: a first generation for a binding nothing has + // applied, declaring no workload. That cannot be authorised at all — + // there is nothing to check the carrier against, and accepting it would + // make the namespace check vacuous for exactly the documents that + // omitted one. + if len(declaredNamespaces) == 0 { + return fmt.Errorf("%w: neither this presence document nor the binding's applied generation names a workload namespace, so there is nothing to authorise its carrier against", + ErrCarrierNotAuthorized) + } + // More than one distinct namespace has no single answer to "where was + // this delivered from". The renderer uses one namespace per render and + // cannot emit such a document, so one that exists was hand-built — + // refused by name rather than resolved by picking one. + if len(declaredNamespaces) > 1 { + return fmt.Errorf("%w: the presence document declares workloads in %d namespaces (%s), so the namespace it was delivered from has no single answer", + ErrCarrierNotAuthorized, len(declaredNamespaces), strings.Join(declaredNamespaces, ", ")) + } + if reviewed.Namespace != declaredNamespaces[0] { + return fmt.Errorf("%w: the presence document's workloads declare namespace %q, and the carrier authenticated in %q", + ErrCarrierNotAuthorized, declaredNamespaces[0], reviewed.Namespace) + } + return nil + } + return fmt.Errorf("%w: unknown document kind %q", ErrCarrierNotAuthorized, kind) +} + +// bearerToken reads the token out of an Authorization header value. +// +// The scheme is matched case-insensitively because HTTP says it is +// case-insensitive, and a caller sending `bearer` would otherwise be refused as +// unauthenticated for a reason no error message would make obvious. +func bearerToken(credential string) string { + fields := strings.Fields(strings.TrimSpace(credential)) + if len(fields) != 2 || !strings.EqualFold(fields[0], "bearer") { + return "" + } + return fields[1] +} + +var _ business.SolutionDeliveryCarrierAuthorizer = (*SolutionDeliveryCarrierCheck)(nil) diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go new file mode 100644 index 000000000..bc9e9de28 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_delivery_carrier_test.go @@ -0,0 +1,281 @@ +//go:build pure + +package infra_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "accounts/pkg/business" + "accounts/pkg/infra" + + "github.com/stretchr/testify/require" +) + +// Carrier authorisation: who may HAND a document to this host, as opposed to who +// signed it. +// +// Every test here is a way the host could accept a document from a caller that +// is not the authorised writer, and the asymmetry between the two kinds is the +// substance: authority has one fixed (service account, namespace) pair, while +// presence is checked against the namespace the document's own workloads +// declare. + +// tokenReviewing stands up an API server that answers TokenReview with a given +// username and audiences, and records what it was asked. +// +// A fake API server rather than a fake client, deliberately: the thing worth +// proving includes that the AUDIENCE is sent, and a fake client would let that +// go untested while looking identical. +func tokenReviewing(t *testing.T, username string, audiences []string, authenticated bool) (*infra.KubernetesClient, *tokenReviewProbe) { + t.Helper() + probe := &tokenReviewProbe{} + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" { + http.NotFound(w, r) + return + } + var request struct { + Spec struct { + Token string `json:"token"` + Audiences []string `json:"audiences"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + probe.token = request.Spec.Token + probe.audiences = request.Spec.Audiences + probe.authorization = r.Header.Get("Authorization") + + response := map[string]any{"status": map[string]any{ + "authenticated": authenticated, + "audiences": audiences, + "user": map[string]any{"username": username}, + }} + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(response)) + })) + t.Cleanup(server.Close) + + client := infra.NewKubernetesClientForTest(t, server) + return client, probe +} + +type tokenReviewProbe struct { + token string + audiences []string + authorization string +} + +// The AUDIENCE is sent on every review. A TokenReview without one validates the +// token for any audience, which would let a token its holder was legitimately +// given for another service authenticate here. +func TestCarrierReviewSendsTheAudience(t *testing.T) { + client, probe := tokenReviewing(t, + "system:serviceaccount:platform-authority:delivery", []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + require.NoError(t, check.AuthorizeCarrier( + context.Background(), "Bearer caller-token", business.SolutionDeliveryAuthority, nil)) + require.Equal(t, []string{"accounts"}, probe.audiences, + "an audience-less review accepts a token minted for any service") + require.Equal(t, "caller-token", probe.token) + require.NotEmpty(t, probe.authorization, + "the host presents its OWN credential to the api server") +} + +// The authority pair is fixed, and both halves of it are checked. +func TestCarrierAuthorityRequiresTheFixedPair(t *testing.T) { + for name, username := range map[string]string{ + "wrong namespace": "system:serviceaccount:platform:delivery", + "wrong service account": "system:serviceaccount:platform-authority:publisher", + "both wrong": "system:serviceaccount:default:default", + } { + t.Run(name, func(t *testing.T) { + client, _ := tokenReviewing(t, username, []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized) + }) + } +} + +// Presence is checked against the namespace THE DOCUMENT declares, not a +// constant. A carrier from the module's own namespace is authorised; the same +// carrier is refused for a document declaring another namespace. +// +// Both directions in one test, so it cannot pass by refusing everything. +func TestCarrierPresenceIsCheckedAgainstTheDocumentsNamespace(t *testing.T) { + client, _ := tokenReviewing(t, + "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + require.NoError(t, check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryPresence, []string{"acme-crm"}), + "a carrier from the namespace the document declares is the authorised writer") + + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryPresence, []string{"acme-other"}) + require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized, + "the same carrier must not deliver a document declaring another namespace") +} + +// A presence document declaring no workload namespace cannot be authorised: there +// is nothing to check the carrier against, and accepting it would make the +// namespace check vacuous for exactly the documents that omitted it. +func TestCarrierPresenceWithNoDeclaredNamespaceIsRefused(t *testing.T) { + client, _ := tokenReviewing(t, + "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryPresence, nil) + require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized) +} + +// Two namespaces have no single answer to "where was this delivered from". The +// renderer uses one namespace per render and cannot emit such a document, so one +// that exists was hand-built — refused by name rather than resolved by picking +// one, which is how a check becomes satisfiable by adding a namespace. +func TestCarrierPresenceWithTwoNamespacesIsRefusedByName(t *testing.T) { + client, _ := tokenReviewing(t, + "system:serviceaccount:acme-crm:delivery", []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + err := check.AuthorizeCarrier(context.Background(), "Bearer t", + business.SolutionDeliveryPresence, []string{"acme-crm", "acme-other"}) + require.ErrorIs(t, err, infra.ErrCarrierNotAuthorized) + require.Contains(t, err.Error(), "no single answer") +} + +// A token the API server REFUSED is terminal and distinct from one it could not +// review. The token is kubelet-projected and re-read per request, so a refusal +// is a configuration fault and a retry asks the same question. +func TestCarrierRefusedTokenIsUnauthenticated(t *testing.T) { + client, _ := tokenReviewing(t, "", nil, false) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated) + require.NotErrorIs(t, err, infra.ErrKubernetesUnavailable, + "a refusal is a verdict, never an outage") +} + +// A token valid for ANOTHER audience is refused, even though the API server +// authenticated it. This is the check that stops a token its holder was +// legitimately given for a different service from delivering documents. +func TestCarrierTokenForAnotherAudienceIsRefused(t *testing.T) { + client, _ := tokenReviewing(t, + "system:serviceaccount:platform-authority:delivery", []string{"some-other-service"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated) +} + +// An identity that is not a service account is refused rather than read as a +// service account in the "" namespace — which, for the presence half where the +// namespace comes from the document, could compare equal to nothing by accident. +func TestCarrierNonServiceAccountIdentityIsRefused(t *testing.T) { + for _, username := range []string{"jane@example.com", "system:node:worker-1", "", "system:serviceaccount:", "system:serviceaccount:ns:"} { + client, _ := tokenReviewing(t, username, []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated, "username %q", username) + } +} + +// No credential at all is refused before the API server is asked. +func TestCarrierWithoutATokenIsRefused(t *testing.T) { + client, probe := tokenReviewing(t, + "system:serviceaccount:platform-authority:delivery", []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + for _, credential := range []string{"", " ", "Basic abc", "Bearer", "Bearer a b"} { + err := check.AuthorizeCarrier( + context.Background(), credential, business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrCarrierUnauthenticated, "credential %q", credential) + } + require.Empty(t, probe.token, "the api server must not be asked about a credential that is not a bearer token") +} + +// `bearer` lowercase is accepted: HTTP says the scheme is case-insensitive, and +// a caller sending it would otherwise be refused for a reason no error message +// would make obvious. +func TestCarrierBearerSchemeIsCaseInsensitive(t *testing.T) { + client, _ := tokenReviewing(t, + "system:serviceaccount:platform-authority:delivery", []string{"accounts"}, true) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + require.NoError(t, check.AuthorizeCarrier( + context.Background(), "bearer t", business.SolutionDeliveryAuthority, nil)) +} + +// An unreachable API server is RETRYABLE and says nothing about the caller. A +// verifier that concluded "not authorised" from its own inability to ask would +// be reporting a verification it did not perform. +func TestCarrierUnreachableApiServerIsNotAVerdict(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + check := infra.NewSolutionDeliveryCarrierCheck(infra.NewKubernetesClientForTest(t, server)) + + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrKubernetesUnavailable) + require.NotErrorIs(t, err, infra.ErrCarrierUnauthenticated) + require.NotErrorIs(t, err, infra.ErrCarrierNotAuthorized) +} + +// A host with no way to review carriers accepts documents from nobody. +func TestCarrierCheckWithoutAClientFailsClosed(t *testing.T) { + check := infra.NewSolutionDeliveryCarrierCheck(nil) + err := check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil) + require.ErrorIs(t, err, infra.ErrKubernetesUnavailable) +} + +// The host re-reads its OWN token per request rather than caching it at boot. +// +// A projected service-account token is rotated by the kubelet at ~80% of its +// lifetime, so a value captured at boot stops working some hours later — which +// presents as the API server refusing the host's own identity, the single most +// confusing failure available here. +func TestHostTokenIsReReadPerRequest(t *testing.T) { + directory := t.TempDir() + tokenPath := filepath.Join(directory, "token") + require.NoError(t, os.WriteFile(tokenPath, []byte("first"), 0o600)) + + seen := []string{} + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{ + "authenticated": true, + "audiences": []string{"accounts"}, + "user": map[string]any{"username": "system:serviceaccount:platform-authority:delivery"}, + }})) + })) + t.Cleanup(server.Close) + + client := infra.NewKubernetesClientForTestWithToken(t, server, tokenPath) + check := infra.NewSolutionDeliveryCarrierCheck(client) + + require.NoError(t, check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil)) + require.NoError(t, os.WriteFile(tokenPath, []byte("rotated"), 0o600)) + require.NoError(t, check.AuthorizeCarrier( + context.Background(), "Bearer t", business.SolutionDeliveryAuthority, nil)) + + require.Equal(t, []string{"Bearer first", "Bearer rotated"}, seen, + "the host's own token must be re-read per request, or it stops working when the kubelet rotates it") +} diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_envelope.go b/module/services/accounts/code/pkg/infra/solution_delivery_envelope.go new file mode 100644 index 000000000..ed7e63de6 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_delivery_envelope.go @@ -0,0 +1,151 @@ +package infra + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/codefly-dev/core/solutionhost" +) + +// The authority ENVELOPE: the ceiling a delivered authority document is held +// against (issue #952). +// +// It is read from the trust anchor, and the two reasons are the same two reasons +// the identity allowlist is read from there. +// +// First, A DOCUMENT MUST NEVER CARRY ITS OWN CEILING. core says so in as many +// words — "an envelope a document carried would be a document declaring its own +// ceiling" — and the consequence is not abstract: ValidateAgainst tests a +// document's approved build and its (principal, binding) grants against the +// envelope's, so an envelope assembled from the delivery tree answers itself for +// every document in it. +// +// Second, it must not be reachable by whoever can set this service's +// environment. Workspace environment is delivered by the COMPOSITION, so a +// configurable ceiling path is a ceiling the composition chooses — the same hole +// SolutionHostTrustAnchorPath exists to close, one axis over. A platform +// administrator writes the ceiling and a platform-owned delivery path mounts it; +// neither delivery writer can. +// +// ABSENCE AND DAMAGE ARE DIFFERENT. A host with no envelope delivered has no +// ceiling, so it activates nothing and says so — authority is simply not a +// question it can answer. A host whose envelope is present and unreadable is a +// host whose ceiling was delivered and cannot be applied, and that refuses the +// boot: reading a damaged ceiling as "no ceiling" would turn a corrupted file +// into a silently narrower system, and reading it as a permissive one would turn +// it into a silently wider one. + +// authorityEnvelopeFileName is the ceiling's name inside the trust anchor. +// Fixed, like the other two: the anchor is written by one platform-owned path, +// and a configurable name is one more thing someone could point elsewhere. +const authorityEnvelopeFileName = "authority_envelope.json" + +// ErrSolutionAuthorityEnvelopeUnusable reports a ceiling that was delivered and +// cannot be applied. +var ErrSolutionAuthorityEnvelopeUnusable = errors.New("solution authority envelope is unusable") + +// solutionAuthorityEnvelopeDocument is the ceiling's wire form. +// +// It is declared here rather than decoded straight into core's Envelope because +// core's type carries no JSON tags — it is a value a host assembles, never a +// document core parses, which is exactly the property that keeps a document from +// carrying one. +type solutionAuthorityEnvelopeDocument struct { + // Revision is the ceiling's revision. Both halves of an activation tuple + // must name it, so a document validated against a superseded ceiling cannot + // activate against the current one. + Revision uint64 `json:"revision"` + + // Grants are the (principal, binding) pairs this ceiling allows. The PAIR, + // because a binding is held BY someone: a ceiling listing bindings alone + // would let a document move one between principals unchanged, and moving a + // binding is a grant of somebody else's authority. + Grants []solutionhost.EnvelopeGrant `json:"grants"` + + // ApprovedBuilds are the builds this ceiling has approved. + ApprovedBuilds []solutionhost.ImageDigest `json:"approvedBuilds"` +} + +// imageDigestPattern is the OCI manifest digest shape, restated because a +// readable stand-in for a digest in a ceiling would approve a build that cannot +// exist — and would read as a ceiling that works until something compared it to +// a real one. +var imageDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// ReadSolutionAuthorityEnvelope reads the ceiling from the trust anchor. +// +// The second return says whether a ceiling is DELIVERED at all. False with no +// error is a host that may activate nothing; an error is a host that must not +// start. +func ReadSolutionAuthorityEnvelope() (solutionhost.Envelope, bool, error) { + path := filepath.Join(SolutionHostTrustAnchorPath, authorityEnvelopeFileName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // No ceiling delivered. Not an error and not an empty ceiling: the + // host answers no authority question at all, which is what + // ActivateDeliveredAuthority refuses by name. + return solutionhost.Envelope{}, false, nil + } + return solutionhost.Envelope{}, false, fmt.Errorf( + "%w: %s is present on the trust anchor and cannot be read: %w", + ErrSolutionAuthorityEnvelopeUnusable, path, err) + } + document := &solutionAuthorityEnvelopeDocument{} + decoder := json.NewDecoder(strings.NewReader(string(data))) + // Strict, for the reason the verification policy is strict: a field this + // host does not model is a version step, not an intention to ignore. + decoder.DisallowUnknownFields() + if err := decoder.Decode(document); err != nil { + return solutionhost.Envelope{}, false, fmt.Errorf("%w: %s does not decode: %w", + ErrSolutionAuthorityEnvelopeUnusable, path, err) + } + if err := document.validate(path); err != nil { + return solutionhost.Envelope{}, false, err + } + return solutionhost.Envelope{ + Revision: document.Revision, + Grants: document.Grants, + ApprovedBuilds: document.ApprovedBuilds, + }, true, nil +} + +// validate refuses a ceiling that would bound less than its author meant. +// +// Every case here is one where the document decodes and reads as a ceiling while +// bounding nothing in particular — which is worse than a missing file, because a +// missing file is reported as "no ceiling" and this would be reported as one. +func (document *solutionAuthorityEnvelopeDocument) validate(path string) error { + if document.Revision == 0 { + return fmt.Errorf("%w: %s names no revision, and a tuple that agrees with itself about no ceiling activates nothing", + ErrSolutionAuthorityEnvelopeUnusable, path) + } + for index, grant := range document.Grants { + switch { + case strings.TrimSpace(grant.Principal) == "": + return fmt.Errorf("%w: %s grant %d names no principal; the ceiling is WHO MAY HOLD WHICH, and a grant with no holder bounds half the statement", + ErrSolutionAuthorityEnvelopeUnusable, path, index) + case strings.TrimSpace(grant.Binding.ID) == "": + return fmt.Errorf("%w: %s grant %d names no binding id; a binding id is what a credential seals and a verifier looks up", + ErrSolutionAuthorityEnvelopeUnusable, path, index) + case grant.Binding.Revision == 0: + return fmt.Errorf("%w: %s grant %d binding %q names no revision; containment is exact, so a revision-less entry matches nothing and reads as an entry that works", + ErrSolutionAuthorityEnvelopeUnusable, path, index, grant.Binding.ID) + case strings.TrimSpace(grant.Binding.Audience) == "" || strings.TrimSpace(grant.Binding.Scope) == "": + return fmt.Errorf("%w: %s grant %d binding %q names no audience or no scope", + ErrSolutionAuthorityEnvelopeUnusable, path, index, grant.Binding.ID) + } + } + for index, build := range document.ApprovedBuilds { + if !imageDigestPattern.MatchString(string(build)) { + return fmt.Errorf("%w: %s approved build %d is %q, which is not a SHA-256 OCI image manifest digest", + ErrSolutionAuthorityEnvelopeUnusable, path, index, build) + } + } + return nil +} diff --git a/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go b/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go new file mode 100644 index 000000000..015b04ee9 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_delivery_production_path_test.go @@ -0,0 +1,534 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "accounts/pkg/adapters" + "accounts/pkg/business" + "accounts/pkg/infra" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// The delivery endpoint, END TO END: the real HTTP handler, the real +// TokenReview carrier check, the real Postgres inbox, and the real reconciler +// reading that inbox. +// +// WHY THIS FILE EXISTS RATHER THAN MORE HANDLER TESTS. The handler tests beside +// `solution_delivery_http.go` wire the service's whole delivery half out of +// fakes: a fake verifier, a fake inbox, a fake carrier authorizer. That is the +// right shape for the response-code table and the wrong shape for everything +// else, because a fake standing in for the thing under test proves the caller +// and never the thing. Three defects survived that suite: +// +// - both paths verified with the PRESENCE reader, so an authority document +// could not be delivered at all and a presence document POSTed to +// /authority was stored as kind=authority; +// - a tombstone declares no workload and therefore no namespace, so the +// carrier check refused it and REMOVAL THROUGH THE API WAS IMPOSSIBLE; +// - nothing read the inbox, because the reconciler's source was the mount. +// +// None of the three is visible when the inbox and the carrier check are fakes. +// So these drive the real ones. +// +// ONE FAKE REMAINS, deliberately and with a reason that is not convenience: the +// BundleVerifier. core's signed fixtures carry `FixtureBundle`, a documented +// PLACEHOLDER — core verifies no attestation and ships none, because a real +// bundle needs a trust root, a transparency-log entry and a certificate that +// expires. Real keyless verification is exercised against an in-process +// Sigstore in solution_host_keyless_verifier_test.go, which is where the +// cryptography belongs. What is under test here is the host's ordering, +// dispatch, persistence and reconcile, all of which are real. + +// The signer identity this host attests delivery as for the fixtures. It is +// core's own, because the shipped documents are written against it. +const productionPathSigner = solutionhost.FixtureDeliveredBy + +// deliveryWriter is the one service account that delivers, for either kind. +const deliveryWriterAccount = "delivery" + +// authorityWriterNamespace is the authority half's fixed writer namespace, as +// the carrier check requires it. +const authorityWriterNamespace = "platform-authority" + +// productionPathDelivery is the whole receiving side, assembled from the real +// pieces. +type productionPathDelivery struct { + service *business.Service + handler http.Handler + + // callerNamespace is the namespace the fake API SERVER authenticates the + // caller in. A fake api server rather than a fake carrier check: the + // namespace comparison, the service-account comparison and the audience on + // the review are all real, and a fake check would let every one of them go + // untested while looking identical. + callerNamespace string +} + +func newProductionPathDelivery(t *testing.T) *productionPathDelivery { + t.Helper() + delivery := &productionPathDelivery{callerNamespace: authorityWriterNamespace} + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" { + http.NotFound(w, r) + return + } + response := map[string]any{"status": map[string]any{ + "authenticated": true, + "audiences": []string{"accounts"}, + "user": map[string]any{ + "username": "system:serviceaccount:" + delivery.callerNamespace + ":" + deliveryWriterAccount, + }, + }} + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(response)) + })) + t.Cleanup(server.Close) + + service, err := business.NewService(testStore) + require.NoError(t, err) + // A WITHDRAWAL IS A NARROWING OF AUTHORITY, so the apply that closes an + // installable target must be witnessed by the external policy log before it + // takes effect. A service with no log cannot withdraw at all — which is the + // correct production behaviour and not a configuration a test should assert + // against, so the double below stands in for the warehouse. It is the LOG + // that is doubled, not the protocol: the receipts and the cursor go to the + // real Postgres store. + service.SetPolicyLog(&productionPathPolicyLog{}, testStore) + service.SetSolutionDelivery( + productionPathVerifier{signer: productionPathSigner}, + testStore, + infra.NewSolutionDeliveryCarrierCheck(infra.NewKubernetesClientForTest(t, server)), + map[string][]string{ + productionPathSigner: {solutionhost.FixtureDomain, productionPathDomain}, + }, + ) + delivery.service = service + delivery.handler = adapters.NewSolutionDeliveryHTTPHandler(service) + return delivery +} + +// post delivers one carrier to one kind's path, as the Job does. +func (d *productionPathDelivery) post( + t *testing.T, kind business.SolutionDeliveryKind, body []byte, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodPost, + adapters.SolutionDeliveryPrefix+string(kind), strings.NewReader(string(body))) + request.Header.Set("Authorization", "Bearer projected-delivery-token") + recorder := httptest.NewRecorder() + d.handler.ServeHTTP(recorder, request) + return recorder +} + +// inbox reads the newest delivered generation per document for one kind, +// through the store method the reconciler itself reads with. +func inbox(t *testing.T, kind business.SolutionDeliveryKind) map[string]*business.SolutionDeliveryRecord { + t.Helper() + var records []*business.SolutionDeliveryRecord + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + var err error + records, err = testStore.ListNewestDeliveredDocuments(ctx, kind) + return err + })) + byID := make(map[string]*business.SolutionDeliveryRecord, len(records)) + for _, record := range records { + byID[record.DocumentID] = record + } + return byID +} + +type productionPathVerifier struct{ signer string } + +func (v productionPathVerifier) VerifyBundle( + context.Context, []byte, json.RawMessage, +) (string, error) { + return v.signer, nil +} + +// --------------------------------------------------------------------------- +// The two kinds are two readers +// --------------------------------------------------------------------------- + +// CORE'S OWN SIGNED FIXTURES, one per kind, through the real handler: each is +// accepted on its own path and refused on the other's. +// +// The fixtures are the point. A document this repository writes for itself +// agrees with this repository's reading of the schema by construction; core's +// are the bytes the renderer produces and the three repositories share. And the +// cross-kind half is what the inverted dispatch could not do: before it, the +// genuine authority fixture was REFUSED at /authority with "schema not +// supported", while the presence fixture POSTed to /authority was accepted and +// filed as kind=authority. +// +// The refusal is asserted as 400 rather than merely "an error". The bytes are +// wrong, not the signer: a 403 would send an operator to look at a signing +// identity that signed the document perfectly well and was simply sent to the +// wrong path. +func TestSignedFixturesAreAcceptedOnTheirOwnKindAndRefusedOnTheOther(t *testing.T) { + presence := signedFixture(t, "presence") + authority := signedFixture(t, "authority") + + delivery := newProductionPathDelivery(t) + + // The presence fixture's workloads declare namespace alpha-region-a-01, and + // the carrier must authenticate there. + delivery.callerNamespace = fixturePresenceNamespace + accepted := delivery.post(t, business.SolutionDeliveryPresence, presence) + requireDelivered(t, accepted) + presenceRow := inbox(t, business.SolutionDeliveryPresence)[solutionhost.FixtureBindingID] + require.NotNil(t, presenceRow, "the presence fixture is not in the inbox under its binding id") + require.Equal(t, uint64(4), presenceRow.Generation) + require.Equal(t, solutionhost.FixtureDomain, presenceRow.OwnershipDomain) + require.Equal(t, productionPathSigner, presenceRow.SignerIdentity) + + delivery.callerNamespace = authorityWriterNamespace + accepted = delivery.post(t, business.SolutionDeliveryAuthority, authority) + requireDelivered(t, accepted) + authorityRow := inbox(t, business.SolutionDeliveryAuthority)[fixtureAuthorityID] + require.NotNil(t, authorityRow, + "the authority fixture is not in the inbox: an authority document could not be delivered at all") + require.Equal(t, uint64(2), authorityRow.Generation) + require.Equal(t, fixtureAuthorityID, authorityRow.DocumentID, + "an authority row is keyed by its OWN id, never by the presence binding it is granted over") + + // Now each on the other's path. + refused := delivery.post(t, business.SolutionDeliveryAuthority, presence) + require.Equal(t, http.StatusBadRequest, refused.Code, + "a presence document POSTed to /authority is wrong BYTES for that path, not a wrong signer") + require.NotContains(t, inbox(t, business.SolutionDeliveryAuthority), solutionhost.FixtureBindingID, + "a presence document was filed as kind=authority") + + delivery.callerNamespace = fixturePresenceNamespace + refused = delivery.post(t, business.SolutionDeliveryPresence, authority) + require.Equal(t, http.StatusBadRequest, refused.Code, + "an authority document POSTed to /presence is wrong BYTES for that path") + require.NotContains(t, inbox(t, business.SolutionDeliveryPresence), fixtureAuthorityID, + "an authority document was filed as kind=presence") +} + +// fixturePresenceNamespace is the namespace core's valid presence fixture +// declares through its workload's SPIFFE ID, which is what its carrier is +// authorised against. +const fixturePresenceNamespace = "alpha-region-a-01" + +// fixtureAuthorityID is the authority id core's valid authority fixture +// declares. It is deliberately NOT FixtureBindingID: an authority document is +// identified by its own id and granted over a binding, and conflating the two +// is what let a withdrawal be reinstated by renaming. +const fixtureAuthorityID = "alpha-region-a-01-authority" + +// signedFixture returns one of core's shipped signed carriers by name, through +// FixturesOf so a fixture that is renamed or dropped fails here rather than +// silently stopping being exercised. +func signedFixture(t *testing.T, name string) []byte { + t.Helper() + for _, fixture := range solutionhost.FixturesOf(solutionhost.DocumentTypeSigned) { + if fixture.Name == name { + require.Equal(t, solutionhost.OutcomeAccepted, fixture.Outcome, + "this test drives the ACCEPTED fixtures; %q is now expected to be rejected", name) + return fixture.Document + } + } + t.Fatalf("core ships no signed fixture named %q", name) + return nil +} + +// requireDelivered accepts 202 and 200 and nothing else. +// +// Both are success and the difference is whether anything changed. The inbox is +// append-only with no DELETE grant, so a fixture whose binding id and +// generation are fixed is 202 the first time this package's database sees it and +// 200 on every run after — and a test that demanded 202 would pass once and then +// fail forever for a reason that has nothing to do with the behaviour. +func requireDelivered(t *testing.T, recorder *httptest.ResponseRecorder) { + t.Helper() + if recorder.Code != http.StatusAccepted && recorder.Code != http.StatusOK { + t.Fatalf("delivery was refused with %d: %s", recorder.Code, recorder.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// Removal through the API, end to end +// --------------------------------------------------------------------------- + +// productionPathDomain is the ownership domain this file's own documents are +// delivered under, and productionPathCoordinate the host they target. +const ( + productionPathDomain = "acme" + productionPathCoordinate = "acme/test/eu-west-1" +) + +// A TOMBSTONE IS ACCEPTED AND THE BINDING IS REMOVED, through the real endpoint +// and the real inbox. +// +// This is the defect that made the delivery API unable to express the one thing +// the whole design rests on. Removal is a GENERATION — nothing deletes from the +// inbox and nothing deletes a binding — and a tombstone declares absence, so it +// carries no workload and therefore no workload namespace. The carrier check +// authorised a presence carrier against the namespaces the document's own +// workloads declare and refused the empty set by name, so every tombstone was +// 403 and a binding delivered through this endpoint could never be withdrawn +// through it. +// +// The fix is not to relax the check. It is to authorise the carrier against the +// namespace this host LAST APPLIED for that binding: its own recorded state, +// from a generation it already verified and admitted, rather than anything the +// arriving document asserts. +// +// The whole lifecycle is driven here because each half alone is satisfiable +// without the other: a tombstone accepted but never reconciled leaves the +// solution serving, and a reconcile that withdraws without the POST having been +// accepted is the state the fake-sourced tests were already in. +func TestTombstoneIsAcceptedAndWithdrawsTheRegistrationEndToEnd(t *testing.T) { + solutionID := uniqueAlias("rm") + namespace := "ns-" + solutionID + present := productionPathBinding(t, solutionID, namespace, 1) + + delivery := newProductionPathDelivery(t) + delivery.callerNamespace = namespace + requireDelivered(t, delivery.post(t, business.SolutionDeliveryPresence, + productionPathCarrier(t, present))) + + reconciler := newProductionPathReconciler(t, delivery.service) + require.NoError(t, reconciler.RunOnce(testCtx)) + + applied := productionPathBindingRecord(t, delivery.service, present.Binding) + require.NotNil(t, applied.Applied, "the delivered generation was never applied from the inbox") + require.Equal(t, uint64(1), applied.Applied.Generation) + require.Equal(t, solutionID, applied.Applied.SolutionID) + require.False(t, applied.Applied.Removed) + + // THE POST THAT WAS IMPOSSIBLE. The tombstone declares no workload, so its + // carrier is authorised against the namespace generation 1 applied. + tombstone := productionPathTombstone(t, present, 2) + receipt := delivery.post(t, business.SolutionDeliveryPresence, productionPathCarrier(t, tombstone)) + if receipt.Code == http.StatusForbidden { + t.Fatalf("the tombstone's carrier was refused, so removal through the API is impossible: %s", + receipt.Body.String()) + } + requireDelivered(t, receipt) + + // The inbox's desired set is the NEWEST generation per document, so the + // tombstone supersedes generation 1 without anything being deleted. + row := inbox(t, business.SolutionDeliveryPresence)[present.Binding] + require.NotNil(t, row) + require.Equal(t, uint64(2), row.Generation) + + require.NoError(t, reconciler.RunOnce(testCtx)) + withdrawn := productionPathBindingRecord(t, delivery.service, present.Binding) + require.NotNil(t, withdrawn.Applied) + require.Equal(t, uint64(2), withdrawn.Applied.Generation) + require.True(t, withdrawn.Applied.Removed, "the tombstone generation did not apply") + require.Equal(t, solutionID, withdrawn.Applied.SolutionID, + "a tombstone keeps the key it withdrew, so the removal stays attributable") + + // And the registry record the solution was served under is tombstoned, which + // is what actually takes it out of service. + registrations, _, err := delivery.service.ListSolutionRegistrations(testCtx, true) + require.NoError(t, err) + for _, registration := range registrations { + if registration.SolutionID == solutionID { + require.NotNil(t, registration.TombstonedAt, + "the registration is still live after the binding was withdrawn") + } + } +} + +// newProductionPathReconciler builds the reconciler over the DURABLE INBOX, +// which is the only source there is. +func newProductionPathReconciler( + t *testing.T, service *business.Service, +) *business.SolutionHostBindingReconciler { + t.Helper() + reconciler, err := business.NewSolutionHostBindingReconciler(service, + business.SolutionHostBindingReconcilerConfig{ + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: productionPathVerifier{signer: productionPathSigner}, + Coordinate: productionPathCoordinate, + Domains: []string{productionPathDomain}, + DomainsBySigner: map[string][]string{productionPathSigner: {productionPathDomain}}, + Interval: time.Minute, + }) + require.NoError(t, err) + return reconciler +} + +func productionPathBindingRecord( + t *testing.T, service *business.Service, bindingID string, +) *business.SolutionHostBindingRecord { + t.Helper() + records, err := service.ListSolutionHostBindings(testCtx) + require.NoError(t, err) + for _, record := range records { + if record.BindingID == bindingID { + return record + } + } + t.Fatalf("no durable record for binding %q", bindingID) + return nil +} + +// productionPathBinding is a present generation for one solution on this test +// host, with its workload in namespace. +func productionPathBinding( + t *testing.T, solutionID, namespace string, generation uint64, +) *solutionhost.SolutionHostBinding { + t.Helper() + document := &solutionhost.SolutionHostBinding{ + Schema: solutionhost.SchemaPresenceV2, + Kind: solutionhost.KindSolution, + Binding: "acme.test." + solutionID, + Generation: generation, + OwnershipDomain: productionPathDomain, + EnvelopeRevision: solutionhost.FixtureEnvelopeRevision, + Host: solutionhost.HostTarget{ + Coordinate: productionPathCoordinate, Component: "saas-host", + }, + Release: solutionhost.Release{ + Publisher: "acme", Name: solutionID, Version: "1.4.0", + Digest: solutionhost.ReleaseDigest(fixtureDigest("release/" + solutionID)), + }, + Routes: []solutionhost.Route{{Alias: solutionID, Surface: solutionhost.SurfaceBackend}}, + Artifacts: []solutionhost.Artifact{{ + Surface: solutionhost.SurfaceBackend, Name: "api", + Release: "acme/" + solutionID + "@1.4.0", + Digest: solutionhost.RenderedDigest(fixtureDigest("rendered/" + solutionID)), + }}, + Workloads: []solutionhost.Workload{{ + Name: "api", Artifact: "api", Container: "api", + Image: solutionhost.Image{ + Repository: "registry.example/acme/" + solutionID, + Digest: solutionhost.ImageDigest(fixtureDigest("image/" + solutionID)), + }, + // The namespace the carrier is authorised against reaches the host + // through this SPIFFE ID and nowhere else, which is what keeps it + // inside the signed bytes. + Identity: solutionhost.WorkloadIdentity{ + Audience: "https://test.acme.example/solutions", + Subject: "system:serviceaccount:" + namespace + ":api", + SPIFFEID: "spiffe://acme.test/ns/" + namespace + "/sa/api", + }, + NonAuthenticating: &[]string{"telemetry-sidecar"}, + }}, + } + require.NoError(t, document.Validate(), "the fixture must be a valid binding") + return document +} + +// productionPathTombstone is the withdrawal of one binding: a higher generation +// that declares nothing at all. +func productionPathTombstone( + t *testing.T, document *solutionhost.SolutionHostBinding, generation uint64, +) *solutionhost.SolutionHostBinding { + t.Helper() + removed := *document + removed.Generation = generation + removed.Routes, removed.Artifacts, removed.Workloads = nil, nil, nil + removed.Modules, removed.Endpoints = nil, nil + removed.Removed = true + require.NoError(t, removed.Validate(), "the tombstone must be a valid binding") + return &removed +} + +// productionPathCarrier wraps a document in the carrier delivery POSTs: the +// CANONICAL bytes, which are the signing input, plus a bundle over them. +func productionPathCarrier(t *testing.T, document *solutionhost.SolutionHostBinding) []byte { + t.Helper() + canonical, err := document.CanonicalBytes() + require.NoError(t, err) + carrier, err := solutionhost.MarshalSigned(&solutionhost.Signed{ + Schema: solutionhost.SchemaSignedV1, + Document: canonical, + Bundle: json.RawMessage(solutionhost.FixtureBundle), + }) + require.NoError(t, err) + return carrier +} + +// productionPathPolicyLog witnesses a narrowing, so a withdrawal can take +// effect. +// +// It is deliberately a RECORDING double rather than a no-op that fabricates a +// receipt out of nothing: the operation id is what the host's idempotency rests +// on, so a log that answered the same receipt for every entry would let the +// protocol pass while recording nothing distinguishable. +type productionPathPolicyLog struct { + mu sync.Mutex + sequence uint64 + records []*business.PolicyLogRecord +} + +func (l *productionPathPolicyLog) Append( + _ context.Context, entry *business.PolicyLogEntry, +) (*business.PolicyLogReceipt, error) { + l.mu.Lock() + defer l.mu.Unlock() + // Idempotent on the operation id, which the interface requires of a real + // log: a retry returns the original receipt rather than appending again. + for _, record := range l.records { + if record.OperationID == entry.OperationID { + return &business.PolicyLogReceipt{ + Receipt: record.Receipt, Sequence: record.Sequence, AppendedAt: record.LoggedAt, + }, nil + } + } + l.sequence++ + receipt := &business.PolicyLogReceipt{ + Receipt: "delivery-test-receipt-" + entry.OperationID, + Sequence: l.sequence, + AppendedAt: time.Now().UTC(), + } + copied := *entry + l.records = append(l.records, &business.PolicyLogRecord{ + PolicyLogEntry: copied, + Receipt: receipt.Receipt, + Sequence: receipt.Sequence, + LoggedAt: receipt.AppendedAt, + }) + return receipt, nil +} + +func (l *productionPathPolicyLog) Entries( + _ context.Context, after uint64, _ int, +) ([]*business.PolicyLogRecord, error) { + l.mu.Lock() + defer l.mu.Unlock() + var out []*business.PolicyLogRecord + for _, record := range l.records { + if record.Sequence > after { + out = append(out, record) + } + } + return out, nil +} + +// wireNarrowingPolicyLog gives a test service the protocol's two halves, so a +// narrowing can actually happen. +// +// WHY EVERY SERVICE IN THIS PACKAGE THAT NARROWS NEEDS THIS. A narrowing of +// authority must be appended to the witness and receipted before it takes +// effect, and `WithPolicyLoggedNarrowing` REFUSES on a host with no log — which +// is the settled fail-closed decision, not an oversight. So a test service built +// without one cannot revoke a principal, remove a member, revoke a delegation or +// archive an organisation at all, and a test that performs one is exercising a +// configuration production is deliberately not meant to have. +// +// The REAL postgres store is the local half, so the receipt still has to commit +// in the same transaction as the narrowing; only the external log is a double. +// That keeps the atomicity these tests are mostly about under test rather than +// stubbing it away. +func wireNarrowingPolicyLog(service *business.Service) *business.Service { + service.SetPolicyLog(&productionPathPolicyLog{}, testStore) + return service +} diff --git a/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go b/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go new file mode 100644 index 000000000..8aaaf9634 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go @@ -0,0 +1,153 @@ +package infra + +import ( + "errors" + "fmt" + "os" + + "github.com/codefly-dev/core/solutionhost" +) + +// The host's attestation check over a delivered carrier (issue #952). +// +// core holds no verifier and never will, and the reason shapes this file: +// signing is KEYLESS over a workload identity, so verifying is a trust root plus +// an identity allowlist that the deployment owns. A library every binary imports +// cannot hold either. +// +// What a verifier returns is the SIGNER IDENTITY, not a yes. The host maps that +// identity to the ownership domains it may deliver under, and core refuses a +// document whose asserted domain the attested signer may not speak for. That +// mapping is why a verifier answering a bare boolean would be unusable: "this is +// signed" does not say "by someone entitled to this name". +// +// THERE IS ONE POLICY. An earlier shape offered a second, `local`, which +// performed no cryptography and attested every carrier as the identity `local`, +// gated on the host's coordinate beginning `local/`. It is deleted, and the +// deletion resolves a contradiction this repository's own documentation carried: +// §8 of SOLUTION_REGISTRATION.md argued at length that there should be no local +// trust *mechanism* — "one verifier, one policy shape, two sets of listed +// identities" — while §7 shipped a policy that attested everything. +// +// The adversarial reviews named the concrete failure: the gate was +// `strings.HasPrefix(coordinate, "local/")` on an OPERATOR-DECLARED string, so a +// deployed host configured with a local coordinate admitted any file any mount +// writer dropped, and the coordinate being self-asserted on both sides meant the +// misconfiguration was not self-defeating. It survived only because keyless was +// unavailable; keyless is built now, so a laptop runs the same verifier against a +// local trust root and a local allowlist. The boundary is data, not code, and a +// deployed host refuses a locally signed document through the ORDINARY check that +// refuses any unlisted identity — not a mode flag, not a coordinate comparison, +// and not a code path that exists only to be disabled. + +// SolutionHostTrustPolicy is how a delivered carrier's bundle is checked. +type SolutionHostTrustPolicy string + +// SolutionHostTrustKeyless verifies a Sigstore bundle against the mirrored trust +// root and the identity allowlist on the trust mount. It is the only policy. +const SolutionHostTrustKeyless SolutionHostTrustPolicy = "keyless" + +// ErrSolutionHostTrustUnavailable reports a trust policy that is named and +// understood but cannot be built on this deployment. +var ErrSolutionHostTrustUnavailable = errors.New("solution host trust policy unavailable") + +// solutionHostBundleVerifierWithPolicy is a verifier that also declares the +// signer-to-domain mapping it was built from. +// +// The two travel together because they come from the same independently +// delivered document. When the mapping lived in a workspace environment variable +// instead, a deployer who could set the environment could widen what an accepted +// signer speaks for without touching the policy at all — the allowlist was +// independent and the thing it authorized was not. +type solutionHostBundleVerifierWithPolicy interface { + solutionhost.BundleVerifier + + // SignerDomains maps each attested signer identity to the ownership domains + // it may deliver under, in the shape core's Host takes. + SignerDomains() map[string][]string +} + +// NewSolutionHostBundleVerifier builds the verifier for a named trust policy. +// +// The coordinate is no longer a parameter, and its absence is the point: when a +// policy's validity depended on the coordinate, the coordinate was an +// operator-declared string doing security work. Nothing here is decided by it +// now — what a host accepts is decided entirely by the trust root and the +// allowlist it was delivered. +// SolutionHostTrustAnchorPath is where the trust root and the verification +// policy are read from, and it is a CONSTANT on purpose. +// +// It was `SOLUTION_HOST_TRUST_MOUNT`, a workspace environment value — and that +// was a hole, not a convenience. Workspace environment is delivered by the +// COMPOSITION, so a composition could repoint the verifier at a policy and a +// root it wrote itself and then sign its own presence documents with a key the +// policy it also wrote happened to list. Every check downstream still passed; it +// was checking against an anchor the attacker chose. +// +// "A document never nominates its own anchor" cannot be enforced by a check, +// because the thing being checked is where the check's own inputs come from. It +// has to hold BY CONSTRUCTION, which means the path is not configurable — not by +// environment, not by flag, not by a default a deployer can override. A +// deployment that needs a different path changes the pod spec that mounts it, +// which is the platform's to render and not the composition's to supply. +// +// The anchor is therefore reachable only by whoever can mount into this pod. +// That is the property, and the constant is what makes it one. +const SolutionHostTrustAnchorPath = "/etc/obin/delivery-trust" + +// RequireSolutionHostTrustAnchor refuses when the trust anchor is not mounted. +// +// It exists so the ABSENCE OF THE ANCHOR IS A BOOT FAILURE THAT NAMES THE +// ANCHOR. Verification already fails without it — the keyless verifier cannot +// read a root it does not have — but the refusal an operator reads then depends +// on which configuration value was consulted first, and an unmounted anchor was +// reported as a missing policy setting. Those send an operator to two different +// places, and only one of them is where the problem is. +// +// It takes no path, for the reason SolutionHostTrustAnchorPath is a constant: a +// function that could be told where to look is a function that will be told, +// and the location of the check's own inputs is exactly what must not be +// anyone's choice. A deployment that needs a different path changes the pod spec +// that mounts it. +func RequireSolutionHostTrustAnchor() error { + info, err := os.Stat(SolutionHostTrustAnchorPath) + if err != nil { + return fmt.Errorf("%w: the delivery trust anchor is not mounted at %s: %w; "+ + "this host verifies every delivered carrier against the root and the identity allowlist delivered "+ + "there, so starting without it would mean serving while claiming to verify", + ErrSolutionHostTrustRootUnavailable, SolutionHostTrustAnchorPath, err) + } + if !info.IsDir() { + return fmt.Errorf("%w: %s is not a directory, and the trust anchor is two documents in one: "+ + "%s and %s", ErrSolutionHostTrustRootUnavailable, SolutionHostTrustAnchorPath, + trustedRootFileName, verificationPolicyFileName) + } + return nil +} + +// NewSolutionHostBundleVerifier builds the one verifier, reading its anchor from +// the fixed path. +// +// It takes no path parameter. An optional path is a configurable path with extra +// steps: a caller that could pass one is a caller that could be given one. +func NewSolutionHostBundleVerifier( + policy SolutionHostTrustPolicy, +) (solutionHostBundleVerifierWithPolicy, error) { + switch policy { + case SolutionHostTrustKeyless: + // Refuses at BOOT when the trust mount has no root or no policy. That + // refusal IS the correct behaviour: a host configured for a policy it + // cannot perform must not start and claim to verify, because refusing + // per document instead makes "this host has no trust root" and "delivery + // is shipping something bad" the same observable. + return NewSolutionHostKeylessVerifier(SolutionHostTrustAnchorPath) + + case "": + return nil, fmt.Errorf( + "SOLUTION_HOST_TRUST_POLICY is required wherever this host answers for a coordinate, and the only value is %q: "+ + "a host must say how it checks a delivered carrier rather than inherit a default", + SolutionHostTrustKeyless) + } + return nil, fmt.Errorf("unknown solution host trust policy %q; the only policy is %q — "+ + "a second one that verified less is what this host deleted", policy, SolutionHostTrustKeyless) +} diff --git a/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier_test.go b/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier_test.go new file mode 100644 index 000000000..4a243c2be --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_host_bundle_verifier_test.go @@ -0,0 +1,57 @@ +package infra_test + +import ( + "strings" + "testing" + + "accounts/pkg/infra" +) + +// The trust policy decides whether a delivered carrier is checked at all, so +// each of these is about a way the host could end up admitting something nobody +// attested. +// +// There is ONE policy now. The `local` policy — which performed no cryptography +// and attested every carrier, gated on the host's coordinate starting `local/` — +// is deleted, and its tests went with it. What replaced them is the keyless +// suite, which exercises the same verifier a laptop would run, against a local +// trust root and a local allowlist. + +// No policy is not a permissive policy: the absence is refused by name at boot +// rather than defaulted, because a host that does not say how it checks a +// carrier must not be guessed at. +// No directory is passed to any of these any more: the anchor's location is a +// constant, deliberately not a parameter. See +// infra.SolutionHostTrustAnchorPath — a path a caller could pass is a path a +// composition could eventually supply, which is the hole the constant closes. +func TestUnsetTrustPolicyIsRefused(t *testing.T) { + verifier, err := infra.NewSolutionHostBundleVerifier("") + if err == nil { + t.Fatal("an unset trust policy must be refused, not defaulted") + } + if verifier != nil { + t.Fatal("a refused policy must yield no verifier") + } + if !strings.Contains(err.Error(), "SOLUTION_HOST_TRUST_POLICY") { + t.Fatalf("error %q does not name the key an operator must set", err) + } +} + +// A policy nobody implements must not fall back to one that does. +// +// `local` is included deliberately: it USED to be a working value, and a +// deployment still carrying it in its configuration must fail to boot rather +// than silently fall through to keyless — which would read as "my local policy +// is still working" while the host had started verifying for real, or worse, +// read as nothing at all. +func TestUnknownTrustPolicyIsRefused(t *testing.T) { + for _, policy := range []infra.SolutionHostTrustPolicy{"trust-me", "local", "none", "KEYLESS"} { + verifier, err := infra.NewSolutionHostBundleVerifier(policy) + if err == nil { + t.Fatalf("trust policy %q must be refused", policy) + } + if verifier != nil { + t.Fatalf("trust policy %q must yield no verifier", policy) + } + } +} diff --git a/module/services/accounts/code/pkg/infra/solution_host_keyless_verifier.go b/module/services/accounts/code/pkg/infra/solution_host_keyless_verifier.go new file mode 100644 index 000000000..9b5ee2128 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_host_keyless_verifier.go @@ -0,0 +1,422 @@ +package infra + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +// Keyless verification of a delivered carrier: the production trust policy. +// +// There is no key. Delivery signs with a workload identity through an OIDC +// provider, Fulcio issues a short-lived certificate for it, and the signature +// plus that certificate plus a transparency-log entry are the bundle. So +// verifying is not "do I hold the right key" but two separate questions: +// +// 1. Is this bundle cryptographically sound against a TRUST ROOT — the Fulcio +// chain that may issue, the Rekor key that may witness, the CT log keys? +// 2. Is the identity in its certificate one this host accepts — which +// repository, which workflow, which ref, which issuer? +// +// Both inputs are the deployment's, and NEITHER may come from the composition's +// own tree. A deployer who can edit the identity allowlist does not need to +// forge a signature: it can replace the verifier. That is why the root and the +// policy are read from a mount a platform-owned delivery path writes, and why a +// delivered document can never nominate its own anchor — the policy is not in +// the tree the document arrives from. +// +// WHAT THIS RETURNS is the attested signer identity, not a yes. The host then +// maps that identity to the ownership domains it may deliver under, and core +// refuses a document whose asserted domain the attested signer may not speak +// for. A verifier answering a bare boolean would be unusable: "this is signed" +// does not say "by someone entitled to this name". + +// ErrNoTransparencyEvidence reports a bundle carrying no transparency-log entry +// at all — neither an inclusion promise nor an inclusion proof. +// +// It is DISTINCT from a signature failure, and the distinction is the whole +// reason this error exists: a signer configured without transparency logging is +// a misconfiguration to go and fix, while a signature that does not verify is an +// attack to go and investigate. They reach an operator looking identical +// otherwise, and the first is far more likely. +var ErrNoTransparencyEvidence = errors.New("solution host carrier has no transparency-log evidence") + +// ErrSolutionHostTrustRootUnavailable reports that the mirrored trust root or +// the verification policy could not be read from the trust mount. +var ErrSolutionHostTrustRootUnavailable = errors.New("solution host trust root unavailable") + +// Filenames inside the trust mount. Fixed rather than configurable: the mount is +// written by one platform-owned path, and a configurable name would be one more +// thing a deployer could point somewhere else. +const ( + trustedRootFileName = "trusted_root.json" + verificationPolicyFileName = "verification_policy.json" +) + +// SolutionHostVerificationPolicy is the identity allowlist: which signers this +// host accepts, and under which ownership domains each may deliver. +// +// It is delivered as a document rather than assembled from environment +// variables because it is a structure — a signer is four matchers and a domain +// list, not a string — and because it must arrive through the same +// independently-controlled path as the trust root. An earlier shape read a bare +// certificate SAN out of one env var, which bakes a weaker allowlist in: a SAN +// with no issuer, no repository and no ref accepts any workflow in any +// repository that can obtain a certificate for that identity. +type SolutionHostVerificationPolicy struct { + // Signers is the allowlist. A carrier is accepted when it matches at least + // one entry WHOLE — every field an entry names must match. + Signers []SolutionHostSigner `json:"signers"` +} + +// SolutionHostSigner is one accepted signer identity, and the domains it speaks +// for. +type SolutionHostSigner struct { + // Name is how this host refers to the signer afterwards: it is the identity + // VerifyBundle returns, and the key the signer-to-domain mapping uses. It is + // the host's own label, deliberately not read out of the certificate — a + // certificate field would change shape with the provider. + Name string `json:"name"` + + // Issuer is the OIDC issuer that must have authenticated the workload. + // Required: without it the allowlist accepts a matching SAN from any issuer, + // and anyone who can stand up an issuer can mint that SAN. + Issuer string `json:"issuer"` + + // SubjectAlternativeName is the workflow identity the certificate must + // carry, exactly. Required. + SubjectAlternativeName string `json:"subjectAlternativeName"` + + // SourceRepositoryURI, BuildConfigURI and SourceRepositoryRef narrow it + // further, and are matched exactly when present. Each is optional in the + // document and IndependentlyRequired is what decides whether leaving one out + // is allowed at all — see Validate. + SourceRepositoryURI string `json:"sourceRepositoryUri,omitempty"` + BuildConfigURI string `json:"buildConfigUri,omitempty"` + SourceRepositoryRef string `json:"sourceRepositoryRef,omitempty"` + + // SourceRepositoryRefPrefix admits every ref under one prefix instead of one + // exact ref, and it exists because an exact ref makes WITHDRAWAL IMPOSSIBLE. + // + // A binding is delivered under release tag A. Withdrawing it needs a + // tombstone, which is signed under whatever tag is current — B. With the ref + // pinned to A, that tombstone fails verification, and since a tombstone is + // the only way to withdraw and nothing deletes from the inbox, the binding + // stays applied FOREVER. The CLI reports the same shape from its side: its + // release policy admits the same repository and workflow path at any + // `refs/tags/*`, and a carrier it no longer admits is re-signed rather than + // refused. + // + // A PREFIX rather than a regular expression, deliberately. An arbitrary + // pattern in a trust policy is its own hazard: `.*` reads as a constraint + // and is none, and a mis-anchored pattern is a widening nobody notices. + // `strings.HasPrefix` is anchored by construction, and Validate requires the + // prefix to start with `refs/` so it cannot be the empty string or a single + // letter matching half the ref space. + SourceRepositoryRefPrefix string `json:"sourceRepositoryRefPrefix,omitempty"` + + // Domains are the ownership domains this signer may deliver under. core + // refuses a document whose asserted domain is not in this list for the + // attested signer, which is what stops an accepted signer from speaking for + // a slice of the binding space it was not given. + Domains []string `json:"domains"` +} + +// refMatches reports why a verified certificate's ref is refused, or "" when it +// is accepted. +// +// Only the prefix rule is applied here; an exact `sourceRepositoryRef` was +// already enforced by sigstore inside the identity. A signer declaring neither +// is accepted on the ref axis, which Validate is what decides is allowed. +func (signer SolutionHostSigner) refMatches(result *verify.VerificationResult) string { + if signer.SourceRepositoryRefPrefix == "" { + return "" + } + if result == nil || result.Signature == nil || result.Signature.Certificate == nil { + // Verification succeeded but reported no certificate, so the ref cannot + // be read. Refused rather than skipped: a rule that silently does not + // run is worse than one that refuses, because the policy still reads as + // though the ref were constrained. + return "verification reported no certificate, so its source ref could not be checked" + } + actual := result.Signature.Certificate.SourceRepositoryRef + if !strings.HasPrefix(actual, signer.SourceRepositoryRefPrefix) { + return fmt.Sprintf("certificate ref %q is not under %q", + actual, signer.SourceRepositoryRefPrefix) + } + return "" +} + +// Validate refuses a policy that would accept more than its author meant. +// +// Every refusal here is a case where the document parses and reads as a policy +// while admitting far more than it appears to. They are checked at BOOT, so a +// policy that cannot do its job stops the host rather than quietly widening it. +func (policy *SolutionHostVerificationPolicy) Validate() error { + if policy == nil || len(policy.Signers) == 0 { + return errors.New("verification policy declares no signers, so no carrier could ever be accepted; " + + "an empty allowlist is refused rather than treated as 'accept none', because it reads as a policy that works") + } + names := map[string]bool{} + for index, signer := range policy.Signers { + switch { + case strings.TrimSpace(signer.Name) == "": + return fmt.Errorf("verification policy signer %d declares no name, and the name is the identity this host attributes a delivery to", index) + case names[signer.Name]: + return fmt.Errorf("verification policy declares signer %q twice; which entry's domains apply would depend on ordering", signer.Name) + case strings.TrimSpace(signer.Issuer) == "": + return fmt.Errorf("verification policy signer %q declares no issuer: a subject alternative name with no issuer is accepted from ANY issuer, "+ + "so anyone who can stand up an issuer can mint that identity", signer.Name) + case strings.TrimSpace(signer.SubjectAlternativeName) == "": + return fmt.Errorf("verification policy signer %q declares no subject alternative name", signer.Name) + case strings.TrimSpace(signer.SourceRepositoryURI) == "" && strings.TrimSpace(signer.BuildConfigURI) == "": + // One of the two is required. A workflow identity alone is not a + // narrow claim: the same workflow path exists in every fork of a + // repository, so an allowlist naming only the SAN accepts a fork's + // run of the same file. + return fmt.Errorf("verification policy signer %q names neither a source repository nor a build config: "+ + "a workflow identity alone matches that same workflow path in any fork, so the repository is what makes the claim narrow", signer.Name) + case signer.SourceRepositoryRef != "" && signer.SourceRepositoryRefPrefix != "": + // Two ways to state one constraint is two places for them to + // disagree — the same argument that keeps the issuer out of the + // extensions. And the disagreement here is silent: sigstore would + // enforce the exact ref and the prefix check would pass trivially, + // so the policy would read as permissive and behave as exact. + return fmt.Errorf("verification policy signer %q declares both an exact source ref and a ref prefix; "+ + "the exact ref would win and the prefix would read as though it had widened anything", signer.Name) + case signer.SourceRepositoryRefPrefix != "" && !strings.HasPrefix(signer.SourceRepositoryRefPrefix, "refs/"): + // An unanchored prefix is the widening nobody notices: "" admits + // every ref, and "r" admits half the ref space while looking like a + // constraint. + return fmt.Errorf("verification policy signer %q declares ref prefix %q, which is not under refs/: "+ + "a prefix that does not name the ref namespace admits refs nobody intended", signer.Name, signer.SourceRepositoryRefPrefix) + case len(signer.Domains) == 0: + return fmt.Errorf("verification policy signer %q may deliver under no ownership domain, so accepting it could not admit anything; "+ + "remove the entry or give it a domain", signer.Name) + } + names[signer.Name] = true + } + return nil +} + +// keylessBundleVerifier verifies a Sigstore bundle against a mirrored trust root +// and an identity allowlist, offline. +type keylessBundleVerifier struct { + verifier *verify.Verifier + policy *SolutionHostVerificationPolicy +} + +// NewSolutionHostKeylessVerifier builds the keyless verifier from a trust mount. +// +// It refuses at BOOT when the mount is absent or unreadable, and that is the +// correct behaviour rather than a limitation: a host configured for a policy it +// cannot perform must not start and claim to be verifying. Refusing per document +// instead would make "this host has no trust root" and "delivery is shipping +// something bad" the same observable, which are the two facts an operator most +// needs to tell apart. +func NewSolutionHostKeylessVerifier(directory string) (solutionHostBundleVerifierWithPolicy, error) { + if strings.TrimSpace(directory) == "" { + return nil, fmt.Errorf("%w: no trust mount is configured, so there is no root to verify against", + ErrSolutionHostTrustRootUnavailable) + } + + rootBytes, err := os.ReadFile(filepath.Join(directory, trustedRootFileName)) + if err != nil { + return nil, fmt.Errorf("%w: cannot read %s from the trust mount %q: %w; "+ + "the mirrored Fulcio/Rekor root is delivered by a platform-owned path, independently of either delivery writer", + ErrSolutionHostTrustRootUnavailable, trustedRootFileName, directory, err) + } + trustedRoot, err := root.NewTrustedRootFromJSON(rootBytes) + if err != nil { + return nil, fmt.Errorf("%w: %s in the trust mount is not a usable trusted root: %w", + ErrSolutionHostTrustRootUnavailable, trustedRootFileName, err) + } + + policyBytes, err := os.ReadFile(filepath.Join(directory, verificationPolicyFileName)) + if err != nil { + return nil, fmt.Errorf("%w: cannot read %s from the trust mount %q: %w; "+ + "a trust root without an identity allowlist would accept any signer Fulcio ever issued for", + ErrSolutionHostTrustRootUnavailable, verificationPolicyFileName, directory, err) + } + policy := &SolutionHostVerificationPolicy{} + decoder := json.NewDecoder(strings.NewReader(string(policyBytes))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(policy); err != nil { + return nil, fmt.Errorf("%w: %s in the trust mount does not decode: %w; "+ + "decoding is strict, so a field this host does not model is an error rather than a silently ignored intention", + ErrSolutionHostTrustRootUnavailable, verificationPolicyFileName, err) + } + if err := policy.Validate(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrSolutionHostTrustRootUnavailable, err) + } + + return NewSolutionHostKeylessVerifierFrom(trustedRoot, policy) +} + +// NewSolutionHostKeylessVerifierFrom builds the verifier over already-loaded +// trust material and an already-validated policy. +// +// It exists so the DECISION is separable from the I/O. Reading a mount and +// deciding whether a carrier is acceptable are different concerns with different +// failure modes, and keeping them in one function would have made the decision +// testable only through files — which is how a verifier ends up exercised +// against no real bundle at all. Tests drive this entry point with an in-process +// Sigstore, so the same code path that production uses is the one under test. +func NewSolutionHostKeylessVerifierFrom( + trustedMaterial root.TrustedMaterial, policy *SolutionHostVerificationPolicy, +) (solutionHostBundleVerifierWithPolicy, error) { + if err := policy.Validate(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrSolutionHostTrustRootUnavailable, err) + } + // WithTransparencyLog(1) and WithObserverTimestamps(1), and deliberately NO + // online verification: the delivered namespaces are default-deny egress, so + // an online Rekor or Fulcio lookup would HANG rather than fail fast — a + // staging timeout instead of a permission error in a test. The bundle + // carries its own evidence, so the lookup is unnecessary as well as + // unreachable. + verifier, err := verify.NewVerifier(trustedMaterial, + verify.WithTransparencyLog(1), + verify.WithObserverTimestamps(1), + ) + if err != nil { + return nil, fmt.Errorf("%w: cannot build the bundle verifier: %w", ErrSolutionHostTrustRootUnavailable, err) + } + return &keylessBundleVerifier{verifier: verifier, policy: policy}, nil +} + +// VerifyBundle checks one carrier and returns the attested signer's name. +// +// The payload is passed separately from the bundle because the bundle attests a +// DIGEST: this verifies that the signature covers exactly these bytes, not that +// some bytes were signed by someone acceptable. +func (v *keylessBundleVerifier) VerifyBundle( + _ context.Context, payload []byte, rawBundle json.RawMessage, +) (string, error) { + signed := &bundle.Bundle{} + if err := signed.UnmarshalJSON(rawBundle); err != nil { + return "", fmt.Errorf("carrier bundle does not decode as a Sigstore bundle: %w", err) + } + return v.verifySignedEntity(signed, payload) +} + +// verifySignedEntity is the decision, over anything that presents as a signed +// entity. Separated from the JSON decode so a test can drive it with an +// in-process Sigstore rather than a hand-assembled bundle document. +func (v *keylessBundleVerifier) verifySignedEntity( + signed verify.SignedEntity, payload []byte, +) (string, error) { + // BEFORE the verifier runs, and on the decoded bundle rather than by + // classifying the verifier's error. + // + // This ordering is load-bearing and the obvious implementation is the wrong + // one. The verifier reports a bundle with no log entry as "not enough + // verified log entries from transparency log: 0 < 1" — the SAME words it + // reports for a bundle whose log key this trust root does not hold. Those + // are opposite faults: one is a signer that never logged, the other is a + // root that cannot check the log it did. A classifier reading that string + // cannot tell them apart, and would hand the second case a message telling + // an operator to go fix their signer. + // + // An RFC 3161 signed timestamp does NOT stand in. It attests WHEN something + // was signed, not THAT it was logged, so a bundle carrying one still has + // nothing an inclusion can be checked against — which is exactly the case a + // looser "has it got any evidence at all?" test admits silently. + if !signed.HasInclusionPromise() && !signed.HasInclusionProof() { + return "", fmt.Errorf("%w: neither an inclusion promise nor an inclusion proof; "+ + "a signer that does not log cannot be verified offline against a mirrored root, and this is NOT a signature failure", + ErrNoTransparencyEvidence) + } + + digest := sha256.Sum256(payload) + artifactPolicy := verify.WithArtifactDigest("sha256", digest[:]) + + // Each allowlist entry is tried WHOLE. A carrier is accepted when it + // satisfies one entry completely, never by matching fields from several — + // which is what a single combined identity policy with per-field alternation + // would allow: this repository's issuer with that repository's workflow. + var failures []string + for _, signer := range v.policy.Signers { + identity, err := signer.certificateIdentity() + if err != nil { + // A policy that validated at boot cannot normally fail here, so + // this is a defect rather than a rejection — and it must not read + // as "this carrier was not signed by an accepted signer". + return "", fmt.Errorf("verification policy signer %q cannot be compiled into an identity matcher: %w", signer.Name, err) + } + result, err := v.verifier.Verify(signed, + verify.NewPolicy(artifactPolicy, verify.WithCertificateIdentity(identity))) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", signer.Name, err)) + continue + } + // The prefix rule runs on the VERIFIED certificate, after the signature + // holds. Checking it before would be reading an unverified claim, and + // checking it inside the identity is not available — sigstore matches + // extensions exactly. + if refused := signer.refMatches(result); refused != "" { + failures = append(failures, fmt.Sprintf("%s: %s", signer.Name, refused)) + continue + } + return signer.Name, nil + } + return "", fmt.Errorf("carrier matched no accepted signer in the verification policy (%d tried): %s", + len(v.policy.Signers), strings.Join(failures, "; ")) +} + +// SignerDomains is the attested-signer-to-ownership-domain mapping this policy +// declares, in the shape core's Host takes. +// +// It comes from the SAME document as the identity allowlist deliberately. When +// the two were separate — the allowlist in the trust mount and the domains in a +// workspace environment variable — a deployer who could set the environment +// could widen what an accepted signer speaks for without touching the +// independently-delivered policy at all. +func (v *keylessBundleVerifier) SignerDomains() map[string][]string { + domains := make(map[string][]string, len(v.policy.Signers)) + for _, signer := range v.policy.Signers { + domains[signer.Name] = append([]string(nil), signer.Domains...) + } + return domains +} + +// certificateIdentity compiles one allowlist entry into a matcher. +// +// Exact values throughout, with no regular expressions reachable from the +// document. A regex in an allowlist is a widening waiting to happen — `.*` in a +// ref pattern accepts every branch, and reads at a glance like a configured +// constraint. +func (signer SolutionHostSigner) certificateIdentity() (verify.CertificateIdentity, error) { + san, err := verify.NewSANMatcher(signer.SubjectAlternativeName, "") + if err != nil { + return verify.CertificateIdentity{}, err + } + issuer, err := verify.NewIssuerMatcher(signer.Issuer, "") + if err != nil { + return verify.CertificateIdentity{}, err + } + // The issuer goes in the IssuerMatcher and NOT in the extensions: sigstore-go + // refuses it in both ("please specify issuer in IssuerMatcher, not + // Extensions") rather than silently preferring one, which is the right call + // — two places to state one constraint is two places for them to disagree. + // The ref prefix is NOT given to sigstore: its extension matching is exact + // and offers no pattern, so a prefix has to be checked after verification + // against the certificate the result reports. Passing the empty string here + // means sigstore does not constrain the ref, and `refMatches` below is what + // constrains it — so the two must stay together, which is why Validate + // refuses a signer declaring neither when a ref rule is required. + return verify.NewCertificateIdentity(san, issuer, certificate.Extensions{ + SourceRepositoryURI: signer.SourceRepositoryURI, + BuildConfigURI: signer.BuildConfigURI, + SourceRepositoryRef: signer.SourceRepositoryRef, + }) +} diff --git a/module/services/accounts/code/pkg/infra/solution_host_keyless_verifier_test.go b/module/services/accounts/code/pkg/infra/solution_host_keyless_verifier_test.go new file mode 100644 index 000000000..f18f3f47e --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_host_keyless_verifier_test.go @@ -0,0 +1,436 @@ +package infra_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "accounts/pkg/infra" + + "github.com/sigstore/sigstore-go/pkg/testing/ca" + "github.com/stretchr/testify/require" +) + +// Keyless verification, exercised against a real in-process Sigstore. +// +// Every test here is about a way the host could admit a document nobody +// entitled attested, and each is driven through the same decision production +// takes. A verifier tested only against hand-written bundle JSON proves its +// decoder works; these prove its policy does. + +const ( + testIssuer = "https://token.example.test" + testIdentity = "https://github.test/acme/delivery/.github/workflows/publish.yaml@refs/heads/main" + testRepository = "https://github.test/acme/delivery" +) + +// acceptedPolicy is a whole, valid production allowlist: it names the source +// repository, which is what makes a workflow identity a narrow claim. +func acceptedPolicy() *infra.SolutionHostVerificationPolicy { + return &infra.SolutionHostVerificationPolicy{ + Signers: []infra.SolutionHostSigner{{ + Name: "delivery", + Issuer: testIssuer, + SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, + Domains: []string{"acme"}, + }}, + } +} + +// reachablePolicy is acceptedPolicy with the repository dropped, and it is only +// reachable through the unvalidated test constructor. +// +// The in-process Sigstore mints leaf certificates carrying ONLY the OIDC issuer +// extension, so no certificate it can produce satisfies a policy naming a source +// repository. Rather than weaken the production requirement to make a test pass, +// the accept path is driven with this shape and the production refusal of it is +// asserted separately in TestVerificationPolicyRefusesWhatWouldAdmitTooMuch. +// +// What that leaves genuinely uncovered here: that the repository and ref +// extensions are compared at all. That comparison is sigstore-go's and is +// exercised by its own suite, not by this one. +func reachablePolicy() *infra.SolutionHostVerificationPolicy { + policy := acceptedPolicy() + policy.Signers[0].SourceRepositoryURI = "" + return policy +} + +// keylessVerifierFor builds a verifier over the in-process Sigstore, bypassing +// the policy's production validation for the reason reachablePolicy documents. +func keylessVerifierFor(t *testing.T, sigstore *ca.VirtualSigstore) any { + t.Helper() + verifier, err := infra.NewKeylessVerifierWithoutPolicyValidation(sigstore, reachablePolicy()) + require.NoError(t, err) + return verifier +} + +// A sound bundle from an allowlisted identity is accepted, and the SIGNER'S NAME +// comes back — not a boolean. The host maps that name to the ownership domains +// the signer may speak for, so a verifier answering yes/no would be unusable: +// "this is signed" does not say "by someone entitled to this name". +func TestKeylessVerifierAcceptsAnAllowlistedSigner(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + payload := []byte(`{"schema":"codefly/solution-host-binding/v2"}`) + entity, err := sigstore.Sign(testIdentity, testIssuer, payload) + require.NoError(t, err) + + verifier := keylessVerifierFor(t, sigstore) + signer, err := infra.VerifySignedEntity(verifier, entity, payload) + require.NoError(t, err) + require.Equal(t, "delivery", signer) + require.Equal(t, map[string][]string{"delivery": {"acme"}}, infra.KeylessSignerDomains(verifier)) +} + +// The signature covers THESE bytes. A bundle that verifies over one payload must +// not verify over another: without binding the digest, an attacker could present +// a genuine attestation beside a document it never covered. +// +// BOTH directions are asserted in one test, deliberately. The negative half +// alone is satisfied by a verifier that errors for any reason at all — and a +// first draft of this test did exactly that: it passed against a mutation that +// removed the digest binding entirely, because the replacement policy option +// failed to construct and the test saw "an error" and was content. Asserting +// that the same bundle DOES verify over its own bytes is what makes the failure +// attributable to the bytes rather than to anything that happens to break. +func TestKeylessVerifierRefusesABundleForDifferentBytes(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + signed := []byte(`{"generation":1}`) + entity, err := sigstore.Sign(testIdentity, testIssuer, signed) + require.NoError(t, err) + verifier := keylessVerifierFor(t, sigstore) + + signer, err := infra.VerifySignedEntity(verifier, entity, signed) + require.NoError(t, err, "the control: this bundle must verify over the bytes it covers") + require.Equal(t, "delivery", signer) + + _, err = infra.VerifySignedEntity(verifier, entity, []byte(`{"generation":99}`)) + require.Error(t, err, "an attestation over other bytes must not admit this payload") +} + +// An identity nobody allowlisted is refused, even though the bundle is +// cryptographically sound and issued by the very same Fulcio. +// +// This is the finding the trust model rests on: a trust root says who MAY issue, +// never who may deliver. Without the allowlist, every identity that CA ever +// certified could speak for this host's bindings. +func TestKeylessVerifierRefusesAnUnlistedIdentity(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + payload := []byte(`{"generation":1}`) + entity, err := sigstore.Sign( + "https://github.test/attacker/delivery/.github/workflows/publish.yaml@refs/heads/main", + testIssuer, payload) + require.NoError(t, err) + + verifier := keylessVerifierFor(t, sigstore) + _, err = infra.VerifySignedEntity(verifier, entity, payload) + require.Error(t, err) + require.Contains(t, err.Error(), "matched no accepted signer") +} + +// The same workflow identity from a DIFFERENT issuer is refused. +// +// A subject alternative name with no issuer is accepted from any issuer, so +// anyone who can stand up an OIDC provider can mint that identity. The policy +// requires the issuer for exactly this reason, and this is the test that would +// fail if the requirement were relaxed to "the SAN is enough". +func TestKeylessVerifierRefusesTheRightIdentityFromTheWrongIssuer(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + payload := []byte(`{"generation":1}`) + entity, err := sigstore.Sign(testIdentity, "https://attacker-issuer.example.test", payload) + require.NoError(t, err) + + verifier := keylessVerifierFor(t, sigstore) + _, err = infra.VerifySignedEntity(verifier, entity, payload) + require.Error(t, err, "the allowlisted SAN from an unlisted issuer must be refused") +} + +// A bundle verified under a root that did NOT issue it is refused. The trust +// root is the anchor, so a verifier that accepted this would be checking +// arithmetic rather than provenance. +func TestKeylessVerifierRefusesABundleFromAnotherRoot(t *testing.T) { + signing, err := ca.NewVirtualSigstore() + require.NoError(t, err) + other, err := ca.NewVirtualSigstore() + require.NoError(t, err) + + payload := []byte(`{"generation":1}`) + entity, err := signing.Sign(testIdentity, testIssuer, payload) + require.NoError(t, err) + + verifier := keylessVerifierFor(t, other) + _, err = infra.VerifySignedEntity(verifier, entity, payload) + require.Error(t, err, "a bundle from a different trust root must be refused") +} + +// A carrier may not satisfy one allowlist entry's issuer and another's +// repository. Each entry is tried WHOLE. +// +// A single combined identity policy with per-field alternation would admit the +// cross product, which is strictly more than any entry's author approved. +func TestKeylessVerifierDoesNotCombineFieldsAcrossAllowlistEntries(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + payload := []byte(`{"generation":1}`) + entity, err := sigstore.Sign(testIdentity, testIssuer, payload) + require.NoError(t, err) + + // Two entries, neither of which the carrier satisfies whole: the first has + // the right issuer with the wrong identity, the second the right identity + // with the wrong issuer. + policy := &infra.SolutionHostVerificationPolicy{Signers: []infra.SolutionHostSigner{ + { + Name: "right-issuer-wrong-identity", Issuer: testIssuer, + SubjectAlternativeName: "https://github.test/acme/other/.github/workflows/publish.yaml@refs/heads/main", + SourceRepositoryURI: testRepository, Domains: []string{"acme"}, + }, + { + Name: "right-identity-wrong-issuer", Issuer: "https://other-issuer.example.test", + SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, Domains: []string{"acme"}, + }, + }} + verifier, err := infra.NewKeylessVerifierWithoutPolicyValidation(sigstore, policy) + require.NoError(t, err) + + _, err = infra.VerifySignedEntity(verifier, entity, payload) + require.Error(t, err, "fields must not be satisfiable across different allowlist entries") +} + +// --------------------------------------------------------------------------- +// The transparency-evidence refusal, and WHERE the check goes +// --------------------------------------------------------------------------- + +// A bundle with no transparency-log entry is refused BY NAME, not as a signature +// failure. +// +// The ordering is the point. sigstore-go reports a missing entry as "not enough +// verified log entries from transparency log: 0 < 1" — the same words it reports +// for a bundle whose log key the trust root does not hold. Those are opposite +// faults: a signer that never logged is a misconfiguration to fix, a root that +// cannot check the log is a deployment problem, and a bad signature is an attack. +// A classifier reading that string would tell an operator to fix the wrong one. +func TestKeylessVerifierRefusesNoTransparencyEvidenceByName(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + payload := []byte(`{"generation":1}`) + entity, err := sigstore.Sign(testIdentity, testIssuer, payload) + require.NoError(t, err) + + verifier := keylessVerifierFor(t, sigstore) + _, err = infra.VerifySignedEntity(verifier, withoutTransparency{entity}, payload) + require.ErrorIs(t, err, infra.ErrNoTransparencyEvidence) + require.Contains(t, err.Error(), "NOT a signature failure", + "the refusal must say what it is not, because the two are confused by default") +} + +// An RFC 3161 signed timestamp does not stand in for transparency evidence: it +// attests WHEN something was signed, not THAT it was logged. This is the case a +// looser "has it got any evidence at all?" check admits silently — the field is +// populated, just not the field that answers the question. +func TestKeylessVerifierRefusesATimestampStandingInForTransparency(t *testing.T) { + sigstore, err := ca.NewVirtualSigstore() + require.NoError(t, err) + payload := []byte(`{"generation":1}`) + entity, err := sigstore.Sign(testIdentity, testIssuer, payload) + require.NoError(t, err) + + verifier, err := infra.NewSolutionHostKeylessVerifierFrom(sigstore, acceptedPolicy()) + require.NoError(t, err) + + timestamped := withoutTransparency{entity} + stamps, stampErr := timestamped.Timestamps() + require.NoError(t, stampErr) + require.NotEmpty(t, stamps, + "the fixture must actually carry a signed timestamp, or this test proves nothing") + + _, err = infra.VerifySignedEntity(verifier, timestamped, payload) + require.ErrorIs(t, err, infra.ErrNoTransparencyEvidence) +} + +// withoutTransparency is a signed entity whose transparency-log entries have +// been dropped, keeping everything else — signature, certificate, timestamps. +// +// Dropping them rather than building a bundle from scratch is deliberate: the +// entity is otherwise exactly one that verifies, so a test using it cannot pass +// for some unrelated reason. +type withoutTransparency struct { + infra.VerifySignedEntityInput +} + +func (withoutTransparency) HasInclusionPromise() bool { return false } +func (withoutTransparency) HasInclusionProof() bool { return false } + +// --------------------------------------------------------------------------- +// The policy document, and the boot-time refusals +// --------------------------------------------------------------------------- + +// Each of these is a policy that parses and reads as a policy while admitting +// far more than its author meant, so each is refused at BOOT rather than per +// document. +func TestVerificationPolicyRefusesWhatWouldAdmitTooMuch(t *testing.T) { + for name, policy := range map[string]*infra.SolutionHostVerificationPolicy{ + "no signers": {}, + "no issuer": {Signers: []infra.SolutionHostSigner{{ + Name: "delivery", SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, Domains: []string{"acme"}, + }}}, + "no repository or build config": {Signers: []infra.SolutionHostSigner{{ + Name: "delivery", Issuer: testIssuer, SubjectAlternativeName: testIdentity, + Domains: []string{"acme"}, + }}}, + "no domains": {Signers: []infra.SolutionHostSigner{{ + Name: "delivery", Issuer: testIssuer, SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, + }}}, + "no subject alternative name": {Signers: []infra.SolutionHostSigner{{ + Name: "delivery", Issuer: testIssuer, + SourceRepositoryURI: testRepository, Domains: []string{"acme"}, + }}}, + "no name": {Signers: []infra.SolutionHostSigner{{ + Issuer: testIssuer, SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, Domains: []string{"acme"}, + }}}, + "duplicate name": {Signers: []infra.SolutionHostSigner{ + { + Name: "delivery", Issuer: testIssuer, SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, Domains: []string{"acme"}, + }, + { + Name: "delivery", Issuer: testIssuer, SubjectAlternativeName: testIdentity, + SourceRepositoryURI: testRepository, Domains: []string{"beta"}, + }, + }}, + } { + t.Run(name, func(t *testing.T) { + require.Error(t, policy.Validate()) + }) + } + require.NoError(t, acceptedPolicy().Validate(), "the accepted policy must still validate") +} + +// Keyless refuses at BOOT when the trust mount has no root, and that refusal is +// the correct behaviour rather than a gap. +// +// A host configured for a policy it cannot perform must not start and claim to +// verify: refusing per document instead makes "this host has no trust root" and +// "delivery is shipping something bad" the same observable, which are the two +// facts an operator most needs to tell apart. +// Driven through NewSolutionHostKeylessVerifier, which still takes a directory, +// rather than through NewSolutionHostBundleVerifier, which no longer does. +// +// The policy-level constructor now reads the fixed anchor path, and asserting +// refuse-at-boot through it would be asserting that a SYSTEM path is absent on +// the machine running the test — true on a workstation and silently untrue on a +// host that has the mount. A test whose verdict depends on the environment it +// runs in is not evidence of this property. +func TestKeylessRefusesAtBootWithoutATrustRoot(t *testing.T) { + verifier, err := infra.NewSolutionHostKeylessVerifier(t.TempDir()) + require.ErrorIs(t, err, infra.ErrSolutionHostTrustRootUnavailable) + require.Nil(t, verifier, "an unusable policy must yield no verifier") + require.Contains(t, err.Error(), "trusted_root.json") +} + +// A trust root with no identity allowlist beside it is refused: it would accept +// any signer that Fulcio ever issued a certificate for. +func TestKeylessRefusesARootWithNoPolicy(t *testing.T) { + directory := t.TempDir() + writeTrustRoot(t, directory) + + // Same reason as above: the directory is the unit under test here, so this + // goes through the keyless constructor rather than the policy dispatcher. + _, err := infra.NewSolutionHostKeylessVerifier(directory) + require.ErrorIs(t, err, infra.ErrSolutionHostTrustRootUnavailable) + require.Contains(t, err.Error(), "verification_policy.json") +} + +// Decoding the policy is STRICT: a field the host does not model is an error, +// never a silently ignored intention. A policy carrying `allowAnyIssuer: true` +// must not be read as the policy without it. +func TestVerificationPolicyDecodingIsStrict(t *testing.T) { + directory := t.TempDir() + writeTrustRoot(t, directory) + require.NoError(t, os.WriteFile(filepath.Join(directory, "verification_policy.json"), + []byte(`{"signers":[{"name":"d","issuer":"i","subjectAlternativeName":"s","sourceRepositoryUri":"r","domains":["acme"],"allowAnyIssuer":true}]}`), + 0o600)) + + _, err := infra.NewSolutionHostKeylessVerifier(directory) + require.ErrorIs(t, err, infra.ErrSolutionHostTrustRootUnavailable) + require.Contains(t, err.Error(), "does not decode") +} + +// writeTrustRoot puts a syntactically valid trusted root in the mount. Its +// contents do not matter to the tests that use it — they are about the policy +// beside it — but it must parse, or the failure under test would be the root's. +func writeTrustRoot(t *testing.T, directory string) { + t.Helper() + root := map[string]any{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "certificateAuthorities": []any{}, + "tlogs": []any{}, + "ctlogs": []any{}, + "timestampAuthorities": []any{}, + } + encoded, err := json.Marshal(root) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(directory, "trusted_root.json"), encoded, 0o600)) +} + +// A ref prefix admits every release tag, because an exact ref makes withdrawal +// impossible. +// +// The failure it prevents, end to end: a binding is delivered under release tag +// A; withdrawing it needs a tombstone, which is signed under whatever tag is +// current — B; with the ref pinned to A that tombstone fails verification; and +// since a tombstone is the only way to withdraw and nothing deletes from the +// inbox, the binding stays applied forever. The CLI reports the same shape from +// its side. +func TestARefPrefixAdmitsEveryReleaseTag(t *testing.T) { + policy := &infra.SolutionHostVerificationPolicy{Signers: []infra.SolutionHostSigner{{ + Name: "platform-delivery", + Issuer: "https://token.actions.githubusercontent.com", + SubjectAlternativeName: "https://example.test/acme/infra/.github/workflows/deliver.yml@refs/tags/v2", + SourceRepositoryURI: "https://example.test/acme/infra", + SourceRepositoryRefPrefix: "refs/tags/", + Domains: []string{"acme"}, + }}} + require.NoError(t, policy.Validate(), "a ref prefix under refs/ is a valid narrowing") +} + +// An exact ref and a prefix together are refused, because the exact one wins and +// the prefix reads as though it had widened something. +func TestAnExactRefAndAPrefixTogetherAreRefused(t *testing.T) { + policy := &infra.SolutionHostVerificationPolicy{Signers: []infra.SolutionHostSigner{{ + Name: "platform-delivery", + Issuer: "https://token.actions.githubusercontent.com", + SubjectAlternativeName: "https://example.test/acme/infra/.github/workflows/deliver.yml@refs/tags/v2", + SourceRepositoryURI: "https://example.test/acme/infra", + SourceRepositoryRef: "refs/tags/v2", + SourceRepositoryRefPrefix: "refs/tags/", + Domains: []string{"acme"}, + }}} + err := policy.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "would win") +} + +// A prefix outside the ref namespace is refused: "" admits every ref and "r" +// admits half the ref space while looking like a constraint. +func TestAnUnanchoredRefPrefixIsRefused(t *testing.T) { + for _, prefix := range []string{"r", "v", "tags/", "/refs/", "refs"} { + policy := &infra.SolutionHostVerificationPolicy{Signers: []infra.SolutionHostSigner{{ + Name: "platform-delivery", + Issuer: "https://token.actions.githubusercontent.com", + SubjectAlternativeName: "https://example.test/acme/infra/.github/workflows/deliver.yml@refs/tags/v2", + SourceRepositoryURI: "https://example.test/acme/infra", + SourceRepositoryRefPrefix: prefix, + Domains: []string{"acme"}, + }}} + require.Error(t, policy.Validate(), "ref prefix %q must be refused", prefix) + } +} diff --git a/module/services/accounts/code/pkg/infra/solution_host_ref_prefix_internal_test.go b/module/services/accounts/code/pkg/infra/solution_host_ref_prefix_internal_test.go new file mode 100644 index 000000000..a4c77dc99 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_host_ref_prefix_internal_test.go @@ -0,0 +1,61 @@ +//go:build pure + +package infra + +import ( + "testing" + + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" + "github.com/stretchr/testify/require" +) + +// The ref prefix is ENFORCED against the verified certificate, not merely +// validated in the policy. +// +// In-package because `refMatches` is private, and private is right: it runs only +// after `Verify` succeeds, on the certificate the result reports. The external +// suite covers the policy's validation rules; this covers the comparison, and +// the two are different claims. A mutation disabling the comparison passed every +// validation test — which is exactly why this file exists. +func TestRefPrefixIsEnforcedAgainstTheVerifiedCertificate(t *testing.T) { + signer := SolutionHostSigner{ + Name: "platform-delivery", + SourceRepositoryRefPrefix: "refs/tags/", + } + resultWithRef := func(ref string) *verify.VerificationResult { + return &verify.VerificationResult{ + Signature: &verify.SignatureVerificationResult{ + Certificate: &certificate.Summary{ + Extensions: certificate.Extensions{SourceRepositoryRef: ref}, + }, + }, + } + } + + // Any release tag is admitted — the whole point, since a tombstone is signed + // under whatever tag is current rather than the one the binding arrived on. + require.Empty(t, signer.refMatches(resultWithRef("refs/tags/v1.0.0"))) + require.Empty(t, signer.refMatches(resultWithRef("refs/tags/v9.9.9-rc1"))) + + // A branch is NOT a release tag, and this is the case the exact-ref pin was + // protecting: without the prefix check the signer would admit it. + refused := signer.refMatches(resultWithRef("refs/heads/main")) + require.NotEmpty(t, refused, "a branch must not pass a refs/tags/ prefix") + require.Contains(t, refused, "refs/heads/main") + require.Contains(t, refused, "refs/tags/") + + // A ref that merely CONTAINS the prefix is refused: HasPrefix is anchored, + // and a substring match here would admit `refs/heads/refs/tags/evil`. + require.NotEmpty(t, signer.refMatches(resultWithRef("refs/heads/refs/tags/v1"))) + + // A verified result reporting no certificate REFUSES rather than skipping. A + // rule that silently does not run is worse than one that refuses, because + // the policy still reads as though the ref were constrained. + require.NotEmpty(t, signer.refMatches(&verify.VerificationResult{})) + require.NotEmpty(t, signer.refMatches(nil)) + + // A signer declaring no prefix is unconstrained on this axis — the policy's + // own Validate decides whether that is allowed, not this comparison. + require.Empty(t, SolutionHostSigner{Name: "n"}.refMatches(resultWithRef("refs/heads/main"))) +} diff --git a/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go b/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go new file mode 100644 index 000000000..2b6965922 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/solution_presence_test_helpers_test.go @@ -0,0 +1,126 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + "time" + + "accounts/pkg/business" + + "github.com/stretchr/testify/require" +) + +// Declared presence, for the tests that install something. +// +// An installation names a solution TARGET, and a target exists only because the +// host applied a present generation for a binding. So a test that installs has +// to declare presence first — there is no longer a free-text identifier it can +// invent. These helpers are that declaration, written through the store methods +// the reconcile pass itself uses rather than raw SQL, so a test cannot construct +// a presence state the reconciler could not. + +// uniqueAlias makes a route alias that no other test in this package has +// declared. It is necessary, not tidiness: `solution_targets_live_solution` is a +// unique index over the alias of every OPEN target, and the package shares one +// database — so two tests declaring "audit" would collide, and the second would +// fail inside a helper with a unique-violation that says nothing about what it +// was testing. +func uniqueAlias(label string) string { + return label + "-" + strings.ToLower(strings.ReplaceAll(business.NewIDString(), "-", ""))[:12] +} + +// fixtureDigest is a well-formed digest for a fixture: the schema requires +// `sha256:` plus exactly 64 hex characters, so a readable stand-in like +// "sha256:" is refused by a CHECK constraint rather than quietly +// stored — which is the constraint doing its job and a helper not doing its own. +func fixtureDigest(seed string) string { + sum := sha256.Sum256([]byte(seed)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// declarePresence opens a live solution target for alias and records the applied +// generation that makes it ACCEPTED, returning the target id. +// +// Both halves are required. The target alone would be a row the catalogue +// refuses: `ListAvailableSolutionTargets` joins the binding and requires an +// applied, non-tombstone generation, because an administrator must not be able to +// consent to a release this host never admitted. A helper that wrote only the +// target would therefore produce an uninstallable target and a confusing failure +// far from its cause. +func declarePresence(t *testing.T, bindingID, alias string) string { + t.Helper() + now := time.Now().UTC() + var targetID string + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + target, err := testStore.OpenSolutionTarget(ctx, bindingID, alias, 1, now) + if err != nil { + return err + } + targetID = target.ID + return testStore.SaveSolutionHostBinding(ctx, appliedPresenceRecord(bindingID, alias, 1, now, false)) + })) + return targetID +} + +// withdrawPresence applies a tombstone generation: it closes the target and +// revokes every active installation of it, exactly as the reconcile pass does. +// A test asserting that a withdrawal ends consent must go through this rather +// than updating the row, or it proves only that an UPDATE works. +func withdrawPresence(t *testing.T, bindingID, targetID, alias string, generation uint64) []business.RevokedInstallation { + t.Helper() + now := time.Now().UTC() + var revoked []business.RevokedInstallation + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + if err := testStore.CloseSolutionTarget(ctx, targetID, generation, now); err != nil { + return err + } + if err := testStore.SaveSolutionHostBinding(ctx, + appliedPresenceRecord(bindingID, alias, generation, now, true)); err != nil { + return err + } + var err error + revoked, err = testStore.RevokeInstallationsOfTarget(ctx, targetID, + "solution target was withdrawn", now) + return err + })) + return revoked +} + +// appliedPresenceRecord is the binding row an applied generation leaves. The +// ownership domain is non-empty deliberately: core refuses an applied record +// without one, and an empty string passes a whole-or-absent constraint while +// being exactly the value it rejects — so a helper that left it blank would +// produce rows that store fine and freeze a reconciler. +func appliedPresenceRecord( + bindingID, alias string, generation uint64, now time.Time, removed bool, +) *business.SolutionHostBindingRecord { + entry := business.SolutionHostBindingGeneration{ + Generation: generation, + Digest: fixtureDigest(bindingID), + Document: "{}", + At: now, + } + applied := &business.SolutionHostBindingApplied{ + SolutionHostBindingGeneration: entry, + SolutionID: alias, + Removed: removed, + Domain: "alpha", + Release: "acme/" + alias + "@1.0.0", + } + if !removed { + applied.Routes = []string{alias} + } + return &business.SolutionHostBindingRecord{ + BindingID: bindingID, + HostCoordinate: "acme/local/host", + HostComponent: "saas-starter", + Desired: &entry, + Applied: applied, + UpdatedAt: now, + } +} diff --git a/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go b/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go index dd7571e5d..cf9561a16 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go @@ -541,12 +541,12 @@ func TestSourceDelegation_ReferenceExchangeRefusesALookupThePersonNeverDelegated // runtime's read-only lookup subset. Installed before the connect, so the // delegation records this binding's digest and stays current. writeOnly, err := business.ParseModulePrincipalRegistry(`{` + - `"` + delegationModule + `":{"tenant":"` + w.otherOrg + `","operation_audiences":{` + + `"` + delegationModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + w.otherOrg + `","operation_audiences":{` + `"` + delegationBinding + `":{"audience":"` + runtimeModule + `",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + `"source_delegation_scopes":[{"resource_kind":"collections","actions":["write"]}]}}},` + - `"` + runtimeModule + `":{"tenant":"` + w.otherOrg + `","operation_audiences":{` + + `"` + runtimeModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + w.otherOrg + `","operation_audiences":{` + `"ingest":{"audience":"docstore-ingest",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]}}}}`) diff --git a/module/services/accounts/code/pkg/infra/source_delegation_test.go b/module/services/accounts/code/pkg/infra/source_delegation_test.go index e8d906b58..e673e6777 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_test.go @@ -60,16 +60,16 @@ func delegationRegistryWith(t *testing.T, otherTenant string, crossTenant bool) cross = `"cross_tenant":true,` } registry, err := business.ParseModulePrincipalRegistry(`{` + - `"` + delegationModule + `":{"tenant":"` + otherTenant + `",` + cross + `"operation_audiences":{` + + `"` + delegationModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + otherTenant + `",` + cross + `"operation_audiences":{` + `"` + delegationBinding + `":{"audience":"` + runtimeModule + `",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + `"source_delegation_scopes":[{"resource_kind":"collections","actions":["read","write"]}]}}},` + - `"` + runtimeModule + `":{"tenant":"` + otherTenant + `",` + cross + `"operation_audiences":{` + + `"` + runtimeModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + otherTenant + `",` + cross + `"operation_audiences":{` + `"ingest":{"audience":"docstore-ingest",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]}}},` + - `"` + otherModule + `":{"tenant":"` + otherTenant + `","operation_audiences":{` + + `"` + otherModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + otherTenant + `","operation_audiences":{` + `"sync":{"audience":"reportservice",` + `"invoke_scopes":[{"resource_kind":"reports","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"reports","actions":["read"]}],` + @@ -168,10 +168,14 @@ func (w *delegationWorld) service(t *testing.T, registry business.ModulePrincipa t.Helper() svc, err := business.NewService(testStore) require.NoError(t, err) + // Revoking a principal or a delegation is a witnessed narrowing, so a + // service with no policy log refuses it outright; see wireNarrowingPolicyLog. + wireNarrowingPolicyLog(svc) secrets, err := business.ParseRegistrationSecrets(delegationModule + ":" + delegationDigest(delegationModuleSecret) + "," + otherModule + ":" + delegationDigest(otherModuleSecret)) require.NoError(t, err) svc.SetModuleIdentitySecrets(secrets) + svc.SetModuleAuthorityReads(currentModuleAuthority{}, nil) svc.SetModuleCapabilities(nil, nil, registry) svc.SetEntitlementChecker(business.NewDefaultEntitlementChecker(testStore)) svc.SetDatasourceConnector(delegationCipher{}, delegationProducer{}, "") @@ -548,7 +552,7 @@ func TestSourceDelegation_BindingChanged(t *testing.T) { w := newDelegationWorld(t) sourceID := w.connect(t, w.org, w.admin) - widened, err := business.ParseModulePrincipalRegistry(`{"` + delegationModule + `":{"tenant":"` + w.otherOrg + `","operation_audiences":{` + + widened, err := business.ParseModulePrincipalRegistry(`{"` + delegationModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + w.otherOrg + `","operation_audiences":{` + `"` + delegationBinding + `":{"audience":"` + runtimeModule + `",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + diff --git a/module/services/accounts/code/pkg/infra/story_jobs_test.go b/module/services/accounts/code/pkg/infra/story_jobs_test.go index 81b43c27e..3e8b1181e 100644 --- a/module/services/accounts/code/pkg/infra/story_jobs_test.go +++ b/module/services/accounts/code/pkg/infra/story_jobs_test.go @@ -124,6 +124,7 @@ func moduleJobSurface( principal := uuid.NewString() // Claiming reads across tenants, so the surface requires a cross-tenant // grant for it — a claim without one is refused before the store is reached. + service.SetModuleAuthorityReads(currentModuleAuthority{}, nil) service.SetModuleCapabilities(store, store, business.ModulePrincipalRegistry{ principal: {Queues: []string{queue}, CrossTenant: true}, }) diff --git a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt index 7e65a6fa7..db755087e 100644 --- a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt +++ b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt @@ -30,6 +30,7 @@ pkg/business/audit.go: (*DurableAuditEmitter).Emit: WithControlPlane pkg/business/audit.go: (*Service).AggregateAuditLog: WithControlPlane pkg/business/audit.go: (*Service).QueryAuditLog: WithControlPlane pkg/business/connector_credentials.go: (*Service).SourceSigningSecret: WithControlPlane +pkg/business/current_installation.go: (*Service).ModuleCurrentInstallation: WithControlPlane pkg/business/datasource_budget.go: (datasourceScheduler).Acquire: WithControlPlane pkg/business/datasource_budget.go: (datasourceScheduler).Blocked: WithControlPlane pkg/business/datasource_deliveries.go: (*Service).RunDatasourceReconcile: WithControlPlane @@ -46,12 +47,15 @@ pkg/business/datasource_github_installation.go: (*Service).RunGitHubInstallation pkg/business/datasource_github_installation.go: (*Service).applyGitHubInstallationAccess: WithControlPlane pkg/business/datasource_github_installation.go: (*Service).applyGitHubInstallationAccess: WithControlPlane pkg/business/events_materialize.go: (*Service).MaterializeSubscriptionsFromCatalog: WithControlPlane +pkg/business/executable_artifacts.go: (*Service).requireConsentedBinding: WithControlPlane pkg/business/follow_fanout.go: (*Service).MaterializeFollowSubscriptions: WithControlPlane pkg/business/follow_fanout.go: (*Service).fanOutFollowNotifications: WithControlPlane pkg/business/follow_fanout.go: (*Service).revokeUndeclaredFollowSubscriptions: WithControlPlane pkg/business/follow_fanout.go: (*Service).revokeUndeclaredFollowSubscriptions: WithControlPlane pkg/business/follow_fanout.go: (*Service).undeliveredFollowers: WithControlPlane pkg/business/gdpr.go: (*Service).PurgeExpiredPrivacyArtifacts: WithControlPlane +pkg/business/installations.go: (*Service).ListAvailableSolutions: WithControlPlane +pkg/business/installations.go: (*Service).resolveInstallableTarget: WithControlPlane pkg/business/invitations.go: (*Service).AcceptInvitation: As(System()) pkg/business/invitations.go: (*Service).CreateInvitation: As(System()) pkg/business/invitations.go: (*Service).InspectInvitationByID: As(System()) @@ -84,21 +88,25 @@ pkg/business/organizations.go: (*Service).ConvergeFixtureOrgMember: As(System()) pkg/business/organizations.go: (*Service).ListOrganizations: WithControlPlane pkg/business/permissions.go: (*Service).DeleteRole: WithControlPlane pkg/business/permissions.go: (*Service).ListRoles: WithControlPlane -pkg/business/permissions.go: (*Service).RevokeRole: WithControlPlane pkg/business/permissions.go: (*Service).UpdateRole: WithControlPlane pkg/business/platform_admin.go: (*Service).GrantPlatformRole: WithControlPlane pkg/business/platform_admin.go: (*Service).ImpersonateUser: WithControlPlane pkg/business/platform_admin.go: (*Service).ImpersonateUser: WithControlPlane pkg/business/platform_admin.go: (*Service).ImpersonateUser: WithControlPlane pkg/business/platform_admin.go: (*Service).ListActiveSessions: WithControlPlane -pkg/business/platform_admin.go: (*Service).RevokePlatformRole: WithControlPlane pkg/business/platform_admin.go: (*Service).RevokeSession: WithControlPlane pkg/business/platform_admin.go: (*Service).SearchUsers: WithControlPlane pkg/business/platform_admin.go: (*Service).StopImpersonation: WithControlPlane pkg/business/platform_admin.go: (*Service).SuspendUser: WithControlPlane pkg/business/platform_admin.go: (*Service).UnsuspendUser: WithControlPlane pkg/business/platform_catalogue.go: (*Service).ListPlatformCatalogue: WithControlPlane +pkg/business/policy_log.go: (*Service).MayServe: WithControlPlane +pkg/business/policy_log.go: (*Service).ReconcilePolicyLog: WithControlPlane +pkg/business/policy_log.go: (*Service).ReconcilePolicyLog: WithControlPlane +pkg/business/policy_log.go: (*Service).WithPolicyLoggedNarrowing: WithControlPlane +pkg/business/policy_log.go: (*Service).WithPolicyLoggedNarrowing: WithControlPlane pkg/business/principals.go: (*Service).GetPrincipal: As(System()) +pkg/business/principals.go: (*Service).principalOrgID: As(System()) pkg/business/principals.go: (*Service).withPrincipalScope: As(System()) pkg/business/principals.go: (*Service).withPrincipalScope: As(System()) pkg/business/privacy_jobs.go: (*Service).finishPrivacyWorkflow: System() @@ -120,9 +128,17 @@ pkg/business/service.go: (*Service).VerifyEventWiring: WithControlPlane pkg/business/service.go: (*Service).createOrganization: WithControlPlane pkg/business/solution_audit_events.go: (*Service).AuditEventTypes: WithControlPlane pkg/business/solution_audit_events.go: (*Service).lookupDeclaredAuditEventType: WithControlPlane -pkg/business/solution_registry.go: (*Service).DeleteSolutionRegistration: WithControlPlane +pkg/business/solution_delivery.go: (*DeliveredSolutionHostBindings).Documents: WithControlPlane +pkg/business/solution_delivery.go: (*Service).ReceiveSolutionDelivery: WithControlPlane +pkg/business/solution_delivery_activation.go: (*SolutionAuthorityActivation).newestDelivered: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionGenerationHistory: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionHostBindings: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).ListSolutionTargets: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).applySolutionHostBinding: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).liveSolutionTargetForBinding: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).recordSolutionHostBindingDesired: WithControlPlane +pkg/business/solution_host_bindings_apply.go: (*Service).recordSolutionHostBindingRefusal: WithControlPlane pkg/business/solution_registry.go: (*Service).ListSolutionRegistrations: WithControlPlane -pkg/business/solution_registry.go: (*Service).PutSolutionRegistration: WithControlPlane pkg/business/source_delegation.go: (*Service).AuthorizeDelegationReferenceExchange: WithControlPlane pkg/business/source_delegation.go: (*Service).CheckSourceDelegationContextRevision: WithControlPlane pkg/business/source_delegation.go: (*Service).ConfirmSourceDelegationParent: WithControlPlane @@ -140,6 +156,7 @@ pkg/business/waitlist.go: (*Service).ReviewWaitlist: As(System()) pkg/business/waitlist.go: (*Service).VerifyWaitlist: As(System()) pkg/business/waitlist.go: (*Service).authorizeAccountCreation: As(System()) pkg/business/waitlist.go: (*Service).convertWaitlistLead: As(System()) +pkg/business/work_context_audience.go: (*Service).declaredAudienceBindingIDs: WithControlPlane pkg/infra/postgres.go: (*PostgresStore).ClearAll: WithControlPlane pkg/infra/postgres.go: (*PostgresStore).LinkIdentity: txbind.BindRequest pkg/infra/postgres.go: (*PostgresStore).RegisterUser: pgx.BeginTxFunc(controlPlane) @@ -155,13 +172,14 @@ pkg/infra/postgres_membership_diagnostic.go: (*PostgresStore).RecordMembershipIn pkg/infra/postgres_mfa.go: (*PostgresStore).MigrateLegacyMFASecrets: WithControlPlane pkg/infra/postgres_mfa_login.go: (*PostgresStore).ConsumeMFALoginTransaction: WithControlPlane pkg/infra/postgres_mfa_login.go: (*PostgresStore).GetActiveMFALoginTransaction: WithControlPlane +pkg/infra/postgres_module_authority.go: (*PostgresStore).LiveModuleAuthority: WithControlPlane pkg/infra/postgres_org.go: (*PostgresStore).CreateOrganization: txbind.BindRequest pkg/infra/postgres_org_identity_providers.go: (*PostgresStore).resolvePreAuthProvider: WithControlPlane pkg/infra/postgres_permissions.go: (*PostgresStore).CreateRole: txbind.BindRequest pkg/infra/postgres_permissions.go: (*PostgresStore).ResolveIdentity: controlPlane.Begin pkg/infra/postgres_role_catalog.go: (*PostgresStore).ImportRoleCatalog: WithControlPlane pkg/infra/postgres_solution_audit_events.go: (*PostgresStore).GetDeclaredAuditEventType: withControlPlaneTx -pkg/infra/postgres_solution_registry.go: (*PostgresStore).ForceSolutionRuntimeBoundarySeedForTest: WithControlPlane +pkg/infra/postgres_solution_registry.go: (*PostgresStore).LiveDeclaredSolutionBindingIDs: WithControlPlane pkg/infra/postgres_solution_registry.go: (*PostgresStore).SolutionRuntimeBoundarySeed: WithControlPlane pkg/infra/postgres_solution_registry.go: (*PostgresStore).SolutionRuntimeBoundarySeeds: WithControlPlane pkg/infra/postgres_webhook_relay.go: (*PostgresWebhookRelay).Deliver: txbind.BindWorker diff --git a/module/services/accounts/code/pkg/infra/warehouse_policy_log.go b/module/services/accounts/code/pkg/infra/warehouse_policy_log.go new file mode 100644 index 000000000..ffaedf98a --- /dev/null +++ b/module/services/accounts/code/pkg/infra/warehouse_policy_log.go @@ -0,0 +1,55 @@ +package infra + +// The external policy log's transport, and why this deployment has none. +// +// SHAPE, from the infrastructure that provisions it: a DAY-partitioned +// `policy_log` table clustered on (operation_id, subject_id) with deletion +// protection, plus a `policy_log_replay` view that dedupes by ROW_NUMBER() over +// operation_id ordered by sequence. Writer and reader are distinct roles, and +// the reader deliberately holds no job-creation permission — so a replay is a +// bounded row read with a restriction, never a query. +// +// WHY NO CLIENT IS WIRED. Not for lack of a transport library: because that +// dataset cannot issue the RECEIPT the protocol is built on. +// `business.PolicyLog.Append` must return a token the LOG minted and a +// MONOTONIC SEQUENCE the LOG assigned, and the presence of those is the whole +// difference between "the log witnessed this narrowing" and "this host decided +// it". A BigQuery dataset gives a writer neither: +// +// - the non-job write paths return no ordinal. `tabledata.insertAll` returns +// only per-row errors, and the Storage Write API's offsets are monotonic +// within one stream, so two replicas appending produce two independent +// counters and no global order for the host's cursor to compare against. +// - ingestion time, which the warehouse does assign, is not readable until +// the streaming buffer flushes — minutes, against an append the protocol +// bounds at five seconds, deliberately, so a slow log costs a refusal +// rather than held locks. +// - reading the row back to learn either is not available to the writer at +// all: the roles are separate precisely so the appender cannot read, and +// the reader holds no job-creation permission. +// +// So the missing piece is a RECEIPT-ISSUING APPENDER inside the warehouse's +// trust domain — the component that owns the writer credential, assigns the +// sequence and hands back the token — and that is infrastructure this service +// does not own. Until it exists, the host wires NO policy log (work.go), which +// means it serves normally and refuses every narrowing of authority. That is +// the protocol's own answer to a host that cannot witness a narrowing. +// +// WHAT MUST NOT GO HERE, and why this file is a comment rather than a type. The +// obvious way to make the four narrowing paths work again is a transport that +// returns a receipt it made up — any sequence, any token. That is the exact +// tautology the protocol exists to prevent: the host would record that the log +// witnessed the narrowing, nothing would have, and a restore of this database +// would silently reinstate the revoked authority with the receipt still sitting +// beside it as evidence that it had not. A client that cannot be exercised +// against the real service, in the service that owns authority, is worth less +// than an honest absence; a client that fabricates the one value the protocol +// checks is worth less than nothing. +// +// The previous occupant of this file, `UnavailablePolicyLog`, refused every +// append and every read. It was the right answer while nothing consulted the +// serving gate, and it became the wrong one the moment the gate reached the +// request path: a host holding a log it can never READ never refreshes +// `reached_at`, so the staleness window closes and the host stops answering +// anything at all. Refusing to narrow authority and refusing to serve are +// different refusals, and a deployment with no transport owes the first only. diff --git a/module/services/accounts/code/pkg/relationcatalog/catalog.go b/module/services/accounts/code/pkg/relationcatalog/catalog.go index 647da7387..87bee5b00 100644 --- a/module/services/accounts/code/pkg/relationcatalog/catalog.go +++ b/module/services/accounts/code/pkg/relationcatalog/catalog.go @@ -105,6 +105,12 @@ var authorities = map[string]Authority{ "plans": {Scope: ScopeGlobal}, "platform_admins": {Scope: ScopeGlobal}, "solution_registrations": {Scope: ScopeGlobal}, + "solution_host_bindings": {Scope: ScopeGlobal}, + "solution_targets": {Scope: ScopeGlobal}, + "solution_generation_history": {Scope: ScopeGlobal}, + "solution_delivery_documents": {Scope: ScopeGlobal}, + "policy_log_commits": {Scope: ScopeGlobal}, + "policy_log_cursor": {Scope: ScopeGlobal}, "datasource_credential_budgets": {Scope: ScopeGlobal}, // Tenant-scoped relations. diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index 3006cc8d0..0306bbddb 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -436,9 +436,11 @@ func doWork(ctx context.Context) (Clean, error) { "/v1/auth/.well-known/jwks.json", adapters.NewJWKSHTTPHandler(service), ) - // Composed-module REST federation: the gateway admits a registration only - // against a token signed here, so a module exchanges its composition-declared - // registration secret for one. Unset means no module may federate. + // TODO(#952, Unit D): remove the registration-secret configuration together + // with the mint handlers in pkg/adapters/module_capabilities_server.go, which + // is protected from edits in this unit. Gateway token exchanges are deleted. + // The remaining mint RPC still consumes this declaration. Its credential has + // no gateway registration endpoint to admit it after the cold cutover. moduleRegistrationSecrets, err := business.ParseRegistrationSecrets( workspaceEnv("federation", "MODULE_REGISTRATION_SECRETS")) if err != nil { @@ -449,16 +451,8 @@ func doWork(ctx context.Context) (Clean, error) { return nil, err } - // Solution registration: the same issuer, a separate declaration. A solution - // remote executes in the host origin with the viewer's credentials, so who - // may publish one is stated on its own key rather than inherited from the - // module list. Unset means no solution may register. - // - // The declaration is handed over as a reader, not as a parsed map: unlike a - // module, a solution mounts against a host that is already serving, so - // authorizing or withdrawing one must not wait for this service to restart. - // It is still parsed once here, so a malformed declaration refuses to boot - // rather than silently denying every registration at runtime. + // Keep the remaining solution mint's reader until its protected handler is + // removed in the same change. Validate configuration before serving it. solutionRegistrationSecrets := func() string { return workspaceEnv("federation", "SOLUTION_REGISTRATION_SECRETS") } @@ -467,6 +461,70 @@ func doWork(ctx context.Context) (Clean, error) { } service.SetSolutionRegistrar(minter, solutionRegistrationSecrets) + // Declared solution presence (issue #952). Delivery POSTs one signed + // SolutionHostBinding carrier per solution instance to the delivery + // endpoint; this host verifies it on receipt and persists it, and the + // reconciler reads its desired set from that durable inbox on every pass, + // asks Core whether each document may be applied, and reconciles what it + // admits into the durable declaration registry. + // + // Unset SOLUTION_HOST_COORDINATE leaves the whole surface off — no + // reconciler and no delivery endpoint — and nothing on this host is + // declared. A coordinate lets this host refuse a document delivered to the wrong + // place, and core's check is the only thing standing between this host and + // another host's desired state. + solutionHostBindingReconciler, err := configuredSolutionHostBindingReconciler(service, store) + if err != nil { + return nil, fmt.Errorf("configure solution host binding reconciler: %w", err) + } + + // The policy log: append → receipt → commit, before authority is narrowed. + // + // THIS DEPLOYMENT HAS NO LOG TRANSPORT, and that is stated by wiring none + // rather than by wiring something that refuses. The difference matters now + // that the serving gate is on the request path: a host holding a log it can + // never reach never refreshes `reached_at`, so the staleness window closes + // and the host stops answering anything — a total outage dressed as + // fail-closed. A host holding NO log has nothing unreconciled to honour, so + // `MayServe` admits and the host serves. + // + // What it cannot do is NARROW authority. `WithPolicyLoggedNarrowing` refuses + // outright, so uninstalling a solution, closing a withdrawn solution + // target, revoking a scope grant and removing a team membership all refuse + // with ErrPolicyLogUnreachable until a transport exists. That is the + // protocol's own answer — a narrowing nothing witnessed is one a restore + // silently undoes — and it is loud rather than silent, below. + // + // WHY THERE IS NO TRANSPORT. The warehouse the log lives in cannot issue the + // receipt the protocol requires. `PolicyLog.Append` must return a token the + // LOG minted and a monotonic sequence the LOG assigned, because the receipt + // is what separates "the log witnessed this" from "this host decided it". A + // BigQuery dataset assigns neither to a writer: the non-job write paths + // return no ordinal, ingestion time is not readable until the streaming + // buffer flushes, and the writer role deliberately holds no read and no + // job-creation permission, so it cannot read its own row back to learn + // either. Closing that needs a receipt-issuing appender inside the + // warehouse's trust domain, which is infrastructure this service does not + // own. Anything else would be this host minting its own receipt — the exact + // tautology the protocol exists to prevent. + service.SetPolicyLog(nil, nil) + w.Warn("no policy log transport is configured: this host SERVES normally and REFUSES every " + + "narrowing of authority (solution uninstall, solution-target close, scope-grant revocation, " + + "team-membership removal), because a narrowing nothing witnessed is one a restore undoes silently") + + // The serving gate, on the request path. Registered for both transports' + // authorization interceptors; with no log wired it admits, which is what + // MayServe answers for a host that has narrowed nothing through the + // protocol. + adapters.RegisterPolicyLogServingGate(service.RequireServing) + + // Enforcement at use. Every module capability path re-reads the live + // installation, producer epoch and binding revision through these, and + // AuthorizeModuleCapability refuses outright when they are absent — a host + // that cannot re-read cannot honour a narrowing, so it must not fall back to + // the declared ceiling alone. + service.SetModuleAuthorityReads(store, nil) + // Permissions plugin: configure signing keys before NewServer builds the // generated gRPC registrations. The ed25519 key is // the SAME one we use for JWT minting (saas-starter's cluster @@ -1171,6 +1229,9 @@ func doWork(ctx context.Context) (Clean, error) { if jobOperationsMonitor != nil { jobOperationsMonitor.Start(ctx) } + if solutionHostBindingReconciler != nil { + solutionHostBindingReconciler.Start(ctx) + } if analyticsWorker != nil { analyticsWorker.Start(ctx) } @@ -1222,6 +1283,14 @@ func doWork(ctx context.Context) (Clean, error) { } cancel() } + if solutionHostBindingReconciler != nil { + sw.Info("stopping solution host binding reconciler") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := solutionHostBindingReconciler.Shutdown(shutdownCtx); err != nil { + sw.Warn("solution host binding reconciler shutdown timed out", wool.ErrField(err)) + } + cancel() + } if emailWorker != nil { sw.Info("stopping email delivery worker") shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -1657,6 +1726,214 @@ func configuredWebAuthn() (rpID, displayName string, origins []string, err error // workspaceEnv reads a key from a named Codefly workspace configuration, // including its secret namespace, and falls back to a plain process variable // for deployments that do not use Codefly's configuration provider. +// configuredSolutionHostBindingReconciler builds the declared-presence +// reconciler and mounts the delivery endpoint, or returns nil when this host +// answers for no coordinate (issue #952). +// +// SOLUTION_HOST_COORDINATE is the ONE declaration that turns the surface on, +// and it is the coordinate the operator declared on the environment the renderer +// read. It is never derived here, because a coordinate this host invented would +// match nothing delivery ever wrote. With no coordinate there is no +// declared-presence surface at all: no reconciler, no delivery endpoint, and +// no new solution presence can be reconciled. +// +// THE SOURCE IS THE DURABLE INBOX AND NOTHING ELSE. It was a directory — a +// projected ConfigMap volume named by a workspace setting — and both that gate +// and the directory reader are gone. The directory could not be the desired set, for a +// reason that is a defect rather than a preference: a mount is the CURRENT set, +// so a document that arrived, was recorded as desired, failed to apply and then +// disappeared from the mount was never retried. The host had recorded that +// delivery wanted something and had no way to want it again. The mount also +// dropped the carrier, so a restore could not re-verify what it had accepted. +// +// Worse, while the gate existed the inbox was DEAD CODE in every deployment: +// unset meant no reconciler, no verifier and no delivery endpoint, and set meant +// the source was the mount — so the table migration 24 creates was written by +// the endpoint and read by nothing. Deleting the gate is what makes the inbox +// the only path, and there is now exactly one: a carrier is POSTed, verified on +// receipt, persisted, and re-verified by every reconcile pass that reads it. +// +// SOLUTION_HOST_BINDING_INTERVAL is optional and exists for a deployment that +// wants a tighter convergence bound than the default. +// +// WHERE the trust root and the allowlist are read from is NOT configurable. It +// is `infra.SolutionHostTrustAnchorPath`, a constant, and there is deliberately +// no environment value for it — see that constant for why an env var was a hole +// rather than a convenience. In short: workspace environment is delivered by the +// composition, so an overridable path lets a composition choose the anchor its +// own documents are checked against, and no amount of checking downstream +// recovers from that. +// +// The allowlist and the domains it grants come from ONE document at that path. +// When the domains lived in this environment instead, a deployer who could set +// the environment could widen what an accepted signer speaks for without +// touching the policy that was supposed to be independent of them. +func configuredSolutionHostBindingReconciler( + service *business.Service, store *infra.PostgresStore, +) (*business.SolutionHostBindingReconciler, error) { + coordinate := strings.TrimSpace(workspaceEnv("federation", "SOLUTION_HOST_COORDINATE")) + if coordinate == "" { + // NO DECLARED-PRESENCE SURFACE ALSO MEANS NO EXECUTION BINDING, and + // that is the coherent answer rather than an oversight. The approved + // build is read from delivered authority documents, and a host with no + // coordinate has no delivery endpoint to receive one — so it approves + // nothing, `SetExecutionBinding` is never called, and `BindExecution` + // answers ErrExecutionUnbound for every caller. A module cannot mint + // here, which is the fail-closed direction: the alternative is a host + // that cannot establish what anyone is running and mints anyway. + return nil, nil + } + // The ownership domains this host accepts delivery from. Required with the + // coordinate for the same reason the coordinate is: Core refuses a document + // from an unstated domain, and without the declaration any delivery could + // claim an unseen binding ID under a domain of its own choosing and own it + // from then on — the applied record cannot bound a binding's FIRST + // generation, because there is nothing yet to compare against. + var domains []string + for _, domain := range strings.Split(workspaceEnv("federation", "SOLUTION_HOST_OWNERSHIP_DOMAINS"), ",") { + if domain = strings.TrimSpace(domain); domain != "" { + domains = append(domains, domain) + } + } + if len(domains) == 0 { + return nil, fmt.Errorf("SOLUTION_HOST_COORDINATE is declared without SOLUTION_HOST_OWNERSHIP_DOMAINS, so this host would accept a binding claimed under any domain a writer chose") + } + // THE TRUST ANCHOR MUST BE THERE, and its absence refuses the boot. + // + // Checked by name, first, and before anything else about verification is + // read. A host that cannot verify a carrier must not start and claim to be + // verifying: refusing per document instead makes "this host has no trust + // root" and "delivery is shipping something bad" the same observable, and + // those are the two facts an operator most needs to tell apart. + // + // First rather than somewhere inside the verifier, because the refusal an + // operator reads has to name the thing that is missing. A policy value read + // before this answered "SOLUTION_HOST_TRUST_POLICY is required" for a host + // whose real problem was an unmounted anchor. + if err := infra.RequireSolutionHostTrustAnchor(); err != nil { + return nil, err + } + // The verifier owns the signer-to-domain mapping, and that is a change from + // reading it out of SOLUTION_HOST_SIGNER_DOMAINS. + // + // Two reasons, both of which the adversarial reviews named. The env var's + // key was a bare certificate SAN — no issuer, no repository, no ref — which + // bakes a weaker allowlist shape into configuration: a SAN alone is accepted + // from any issuer, and the same workflow path exists in every fork. And the + // allowlist and the thing it authorizes were separable, so a deployer who + // could set the environment could widen what an accepted signer speaks for + // without touching the independently-delivered policy at all. + // + // Now both come from one document at a FIXED path, which a platform-owned + // delivery path writes and neither delivery writer can. + verifier, err := infra.NewSolutionHostBundleVerifier( + infra.SolutionHostTrustPolicy(strings.TrimSpace(workspaceEnv("federation", "SOLUTION_HOST_TRUST_POLICY")))) + if err != nil { + return nil, err + } + // Every domain the policy grants a signer must be one this host accepts at + // all. Refused BY NAME rather than intersected silently: a policy naming a + // domain SOLUTION_HOST_OWNERSHIP_DOMAINS does not list is a disagreement + // between two independently delivered documents, and quietly dropping the + // entry reads exactly like a policy that works until the binding it was + // meant to admit is withheld. + domainsBySigner := verifier.SignerDomains() + accepted := make(map[string]bool, len(domains)) + for _, domain := range domains { + accepted[domain] = true + } + for signer, granted := range domainsBySigner { + for _, domain := range granted { + if !accepted[domain] { + return nil, fmt.Errorf( + "the verification policy lets signer %q deliver under ownership domain %q, which SOLUTION_HOST_OWNERSHIP_DOMAINS does not accept", + signer, domain) + } + } + } + interval := business.SolutionHostBindingReconcileInterval + if raw := strings.TrimSpace(workspaceEnv("federation", "SOLUTION_HOST_BINDING_INTERVAL")); raw != "" { + parsed, err := time.ParseDuration(raw) + if err != nil || parsed <= 0 { + return nil, fmt.Errorf("SOLUTION_HOST_BINDING_INTERVAL %q is not a positive duration", raw) + } + interval = parsed + } + // The ONLY way a document reaches this host is the delivery endpoint, so a + // host that cannot review a carrier's credential has no input at all. + // Saying so at boot beats a reconciler that polls an inbox nothing can ever + // write to. + kubernetes, err := infra.NewKubernetesClient() + if err != nil { + return nil, fmt.Errorf("the declared-presence surface has no input: a delivered carrier is authorised by TokenReview, and this host cannot reach its api server: %w", err) + } + service.SetSolutionDelivery(verifier, store, infra.NewSolutionDeliveryCarrierCheck(kubernetes), domainsBySigner) + // Deliberately not routed at the gateway, and the asymmetry with the + // credential mint is the reason. `POST /platform/_credential` is brokered by + // the gateway because a solution runtime is an independently deployed + // workload that must not reach accounts' internal listener. A delivery Job + // is not that: it runs in-cluster as one of two known service accounts, so + // routing it through the edge would put accounts-audience tokens across the + // perimeter for no gain. + // + // codefly:gateway-route-exempt delivery is in-cluster and reaches accounts directly, never the public edge + adapters.RegisterHTTPRoute(adapters.SolutionDeliveryPrefix, adapters.NewSolutionDeliveryHTTPHandler(service)) + + // The AUTHORITY CEILING, from the same platform-owned anchor as the trust + // root and for the same two reasons: a document must never carry its own + // ceiling, and a ceiling the composition could point at is a ceiling the + // composition chose. + // + // Absence and damage are different answers. No envelope delivered is a + // complete deployment that answers no authority question — presence + // reconciles and every activation refuses by name — while an envelope that + // IS delivered and cannot be read refuses the boot, because reading a + // damaged ceiling as "no ceiling" turns a corrupted file into a silently + // narrower system and reading it permissively turns it into a wider one. + envelope, _, err := infra.ReadSolutionAuthorityEnvelope() + if err != nil { + return nil, err + } + reconciler, err := business.NewSolutionHostBindingReconciler(service, business.SolutionHostBindingReconcilerConfig{ + Source: business.NewDeliveredSolutionHostBindings(service), + Verifier: verifier, + Coordinate: coordinate, + Domains: domains, + DomainsBySigner: domainsBySigner, + Interval: interval, + Envelope: envelope, + }) + if err != nil { + return nil, err + } + + // EXECUTION-BOUND MINTING, wired here because this is where both of its + // independent sources exist. + // + // THIS IS THE LINE THAT WAS MISSING. `BindExecution`, the three digest + // types, the monotonicity guard and the TokenReview client were all built, + // guarded and unit-tested, and `SetExecutionBinding` was called from + // nowhere — so on a running host the reviewer was nil, `BindExecution` + // answered ErrExecutionUnbound for every caller, and the whole mechanism was + // unreachable. A check nothing calls is indistinguishable from a check that + // passes, which is why this is wired rather than merely available. + // + // THE TWO SOURCES ARE DELIBERATELY DIFFERENT ONES, and that is the entire + // design. The reviewer is the Kubernetes API, keyed by a pod UID that came + // from a TokenReview of a token the caller could not forge: it answers what + // the caller IS RUNNING. The authority answers what THIS HOST APPROVES, read + // from signed documents the caller has no influence over. Filling both sides + // from one source compares a value to itself and passes for every caller, + // including the superseded pod the check exists to refuse. + // + // It is the SAME client that reviews a delivery carrier, which is right + // rather than merely convenient: one api server, one in-cluster credential, + // one place a lost credential stops both halves at once instead of leaving + // one of them silently answering. + service.SetExecutionBinding(kubernetes, reconciler.ApprovedBuilds()) + return reconciler, nil +} + func workspaceEnv(configuration, key string) string { if value, err := codefly.For(codefly.Context()).WorkspaceValue(configuration, key); err == nil && value != "" { return value diff --git a/module/services/accounts/code/work_auth_test.go b/module/services/accounts/code/work_auth_test.go index 57f6e0d4d..921e74543 100644 --- a/module/services/accounts/code/work_auth_test.go +++ b/module/services/accounts/code/work_auth_test.go @@ -519,7 +519,7 @@ func TestConfigureModuleIdentity(t *testing.T) { "documents": sha256.Sum256([]byte("registration-secret")), }) registry, err := business.ParseModulePrincipalRegistry( - `{"documents":{"tenant":"` + tenant + `"}}`) + `{"documents":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + tenant + `"}}`) require.NoError(t, err) service.SetModuleCapabilities(nil, nil, registry) diff --git a/module/services/accounts/code/work_solution_delivery_test.go b/module/services/accounts/code/work_solution_delivery_test.go new file mode 100644 index 000000000..2d994b24f --- /dev/null +++ b/module/services/accounts/code/work_solution_delivery_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "os" + "strings" + "testing" + + "accounts/pkg/infra" + + "github.com/stretchr/testify/require" +) + +// The declared-presence surface's BOOT path (issue #952). +// +// These drive `configuredSolutionHostBindingReconciler` itself rather than +// reading work.go, because every one of them is a refusal that has to happen +// before the service is wired — and a refusal that is only asserted in prose is +// a refusal that stops happening the first time the configuration block is +// reordered. + +// clearSolutionHostEnvironment empties every source workspaceEnv reads the +// federation group from, so an ambient value cannot decide a case. +func clearSolutionHostEnvironment(t *testing.T) { + t.Helper() + for _, key := range []string{ + "SOLUTION_HOST_COORDINATE", + "SOLUTION_HOST_OWNERSHIP_DOMAINS", + "SOLUTION_HOST_TRUST_POLICY", + "SOLUTION_HOST_BINDING_INTERVAL", + // The deleted setting. Cleared too, so a case cannot be decided by an + // ambient value for a key nothing is allowed to read any more. + "SOLUTION_HOST_BINDINGS_DIR", + } { + t.Setenv(key, "") + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__FEDERATION__"+key, "") + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__FEDERATION__"+key, "") + } +} + +func setSolutionHost(t *testing.T, key, value string) { + t.Helper() + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__FEDERATION__"+key, value) +} + +// NO COORDINATE, NO SURFACE. The coordinate is the one declaration that turns +// declared presence on, and without it there is no reconciler, no delivery +// endpoint and no boot requirement — which is what lets a laptop run the graph +// with every solution present because it heartbeats. +func TestNoCoordinateMeansNoDeclaredPresenceSurface(t *testing.T) { + clearSolutionHostEnvironment(t) + // Set everything else, including the deleted mount setting. None of it may + // bring the surface up on its own. + setSolutionHost(t, "SOLUTION_HOST_OWNERSHIP_DOMAINS", "acme") + setSolutionHost(t, "SOLUTION_HOST_TRUST_POLICY", "keyless") + setSolutionHost(t, "SOLUTION_HOST_BINDINGS_DIR", t.TempDir()) + + reconciler, err := configuredSolutionHostBindingReconciler(nil, nil) + require.NoError(t, err) + require.Nil(t, reconciler) +} + +// A COORDINATE WITH NO OWNERSHIP DOMAINS REFUSES TO BOOT. Core refuses a +// document from an unstated domain, and the reason it must be stated is that the +// applied record cannot bound a binding's FIRST generation: with no declaration, +// any delivery could claim an unseen binding ID under a domain of its own +// choosing and own it from then on. +func TestACoordinateWithoutOwnershipDomainsRefusesToBoot(t *testing.T) { + clearSolutionHostEnvironment(t) + setSolutionHost(t, "SOLUTION_HOST_COORDINATE", "acme/prod/eu-west-1") + setSolutionHost(t, "SOLUTION_HOST_TRUST_POLICY", "keyless") + + _, err := configuredSolutionHostBindingReconciler(nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "SOLUTION_HOST_OWNERSHIP_DOMAINS") +} + +// AN ABSENT TRUST ANCHOR REFUSES THE BOOT, and the refusal names the anchor. +// +// This is the whole of the host's verification story in one assertion: a host +// that cannot verify a delivered carrier must not start. Refusing per document +// instead would make "this host has no trust root" and "delivery is shipping +// something bad" the same observable, and those are the two facts an operator +// most needs to tell apart. +// +// It is asserted BEFORE the trust-policy value is read, which is the ordering +// half. An unmounted anchor used to be reported as a missing +// SOLUTION_HOST_TRUST_POLICY — a message that sends an operator to edit +// configuration for a problem that is a pod spec. +func TestAnAbsentTrustAnchorRefusesTheBoot(t *testing.T) { + requireTrustAnchorAbsent(t) + clearSolutionHostEnvironment(t) + setSolutionHost(t, "SOLUTION_HOST_COORDINATE", "acme/prod/eu-west-1") + setSolutionHost(t, "SOLUTION_HOST_OWNERSHIP_DOMAINS", "acme") + // Deliberately NOT set, so that a refusal naming the policy rather than the + // anchor fails this test. The anchor is the more fundamental absence and the + // one the message must name. + _, err := configuredSolutionHostBindingReconciler(nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), infra.SolutionHostTrustAnchorPath, + "the refusal must name the anchor that is missing, not the configuration value read after it") + require.NotContains(t, err.Error(), "SOLUTION_HOST_TRUST_POLICY", + "an unmounted anchor reported as a missing policy setting sends an operator to the wrong place") + + // And with the policy declared, the refusal is the same one: the anchor's + // absence is not something a configuration value can satisfy. + setSolutionHost(t, "SOLUTION_HOST_TRUST_POLICY", "keyless") + _, err = configuredSolutionHostBindingReconciler(nil, nil) + require.ErrorIs(t, err, infra.ErrSolutionHostTrustRootUnavailable) + require.Contains(t, err.Error(), infra.SolutionHostTrustAnchorPath) +} + +// THE MOUNT SETTING IS GONE FROM THE CODE, not merely unused. +// +// A mechanical check, because the gate it replaces was the defect: while +// SOLUTION_HOST_BINDINGS_DIR existed, an unset value meant no reconciler, no +// verifier and no delivery endpoint, and a set value meant the reconciler's +// source was the directory — so the durable inbox the delivery endpoint writes +// was read by NOTHING in either configuration. Re-adding the setting is a +// one-line change that reads like restoring local development and silently +// restores a mount as the desired set, with the retry gap and the dropped +// carrier that cost. +func TestTheBindingsMountSettingIsNotRead(t *testing.T) { + source, err := os.ReadFile("work.go") + require.NoError(t, err) + require.NotContains(t, string(source), "SOLUTION_HOST_BINDINGS_DIR", + "the mount is gone: the reconciler's source is the durable inbox, unconditionally") + + // And the reader itself is gone, so there is nothing for a future edit to + // point at. A constructor left in place is a mount one line away. + for _, name := range []string{ + "pkg/infra/solution_host_binding_mount.go", + "pkg/infra/solution_host_binding_mount_test.go", + } { + _, err := os.Stat(name) + require.True(t, os.IsNotExist(err), "%s still exists", name) + } +} + +// requireTrustAnchorAbsent skips rather than lies when a machine happens to have +// the anchor mounted. +// +// The honest statement of what these two cases prove: the ABSENT case is +// exercised here, and the present case is not, because the anchor's path is a +// constant under /etc that a test must not be able to write — which is the +// property that makes it a constant. The present path is exercised against an +// in-process Sigstore in solution_host_keyless_verifier_test.go. +func requireTrustAnchorAbsent(t *testing.T) { + t.Helper() + if _, err := os.Stat(infra.SolutionHostTrustAnchorPath); err == nil { + t.Skipf("%s exists on this machine, so the absent-anchor refusal cannot be exercised here", + infra.SolutionHostTrustAnchorPath) + } +} + +// The anchor check itself, at its own seam: the sentinel and the path. +func TestRequireSolutionHostTrustAnchorNamesWhatIsMissing(t *testing.T) { + requireTrustAnchorAbsent(t) + err := infra.RequireSolutionHostTrustAnchor() + require.ErrorIs(t, err, infra.ErrSolutionHostTrustRootUnavailable) + require.True(t, strings.Contains(err.Error(), infra.SolutionHostTrustAnchorPath)) +} diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index af3b8f282..f2cd9bbd0 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -3123,6 +3123,86 @@ "policy_sha256": "f44d4ffd9edf43aa97546d85433b347bfcaa1d5d3d870b0a6dbc2a65431b638e", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, + { + "procedure": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "full_service": "saas.accounts.v1.InstallationService", + "method": "ListAvailableSolutions", + "input_type": "saas.accounts.v1.ListAvailableSolutionsRequest", + "output_type": "saas.accounts.v1.ListAvailableSolutionsResponse", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "permissions": [], + "scopes": [], + "resource_bindings": [ + { + "request_field": "org_id", + "target": "RESOURCE_TARGET_ORGANIZATION", + "lookup": "RESOURCE_LOOKUP_DIRECT_ID" + } + ], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_READ", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "org_admin", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "saas/accounts/v1/installations.proto", + "policy_sha256": "ef6a5e6e68c7115ca8783422cdc54febdd089c9b2660ac3774c7a4a4847e7270", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, + { + "procedure": "/saas.accounts.v1.InstallationService/ListInstallations", + "full_service": "saas.accounts.v1.InstallationService", + "method": "ListInstallations", + "input_type": "saas.accounts.v1.ListInstallationsRequest", + "output_type": "saas.accounts.v1.ListInstallationsResponse", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_INTERNAL", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "internal", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "saas/accounts/v1/installations.proto", + "policy_sha256": "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, { "procedure": "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "full_service": "saas.accounts.v1.InstallationService", @@ -8216,11 +8296,11 @@ "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { - "procedure": "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", - "full_service": "saas.accounts.v1.SolutionRegistryService", - "method": "DeleteSolutionRegistration", - "input_type": "saas.accounts.v1.DeleteSolutionRegistrationRequest", - "output_type": "saas.accounts.v1.SolutionRegistration", + "procedure": "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements", + "full_service": "saas.accounts.v1.SolutionEntitlementService", + "method": "ListSolutionEntitlements", + "input_type": "saas.accounts.v1.ListSolutionEntitlementsRequest", + "output_type": "saas.accounts.v1.ListSolutionEntitlementsResponse", "client_streaming": false, "server_streaming": false, "policy": { @@ -8231,10 +8311,8 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [ - "saas.solution.registration_deleted" - ], - "emission": "AUDIT_EMISSION_SUCCESS" + "events": [], + "emission": "AUDIT_EMISSION_NONE" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", @@ -8250,16 +8328,16 @@ "module": "saas-starter", "service": "accounts" }, - "source_proto": "saas/accounts/v1/solution_registry_service.proto", - "policy_sha256": "f7ede710e2635a205b5ffc3e8079f7702560255d5b4c875d7b6af8e041998023", + "source_proto": "saas/accounts/v1/solution_entitlements_service.proto", + "policy_sha256": "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { - "procedure": "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", + "procedure": "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", "full_service": "saas.accounts.v1.SolutionRegistryService", - "method": "ListSolutionRegistrations", - "input_type": "saas.accounts.v1.ListSolutionRegistrationsRequest", - "output_type": "saas.accounts.v1.ListSolutionRegistrationsResponse", + "method": "ListSolutionHostBindings", + "input_type": "saas.accounts.v1.ListSolutionHostBindingsRequest", + "output_type": "saas.accounts.v1.ListSolutionHostBindingsResponse", "client_streaming": false, "server_streaming": false, "policy": { @@ -8292,11 +8370,11 @@ "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { - "procedure": "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration", + "procedure": "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", "full_service": "saas.accounts.v1.SolutionRegistryService", - "method": "PutSolutionRegistration", - "input_type": "saas.accounts.v1.PutSolutionRegistrationRequest", - "output_type": "saas.accounts.v1.SolutionRegistration", + "method": "ListSolutionRegistrations", + "input_type": "saas.accounts.v1.ListSolutionRegistrationsRequest", + "output_type": "saas.accounts.v1.ListSolutionRegistrationsResponse", "client_streaming": false, "server_streaming": false, "policy": { @@ -8307,10 +8385,8 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [ - "saas.solution.registration_updated" - ], - "emission": "AUDIT_EMISSION_SUCCESS" + "events": [], + "emission": "AUDIT_EMISSION_NONE" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", @@ -8327,7 +8403,7 @@ "service": "accounts" }, "source_proto": "saas/accounts/v1/solution_registry_service.proto", - "policy_sha256": "1186e85b490827dcfb7569fc99d65525ad21c30b1fa9652f67bbe7af76670371", + "policy_sha256": "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, { diff --git a/module/services/accounts/generated/gateway-routes.json b/module/services/accounts/generated/gateway-routes.json index 542fd52da..96f63ee52 100644 --- a/module/services/accounts/generated/gateway-routes.json +++ b/module/services/accounts/generated/gateway-routes.json @@ -1121,6 +1121,22 @@ "rewrite_path": "/saas.accounts.v1.InstallationService/InstallSolution", "remove_after": "2026-10-11" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/customers.InstallationService/ListAvailableSolutions", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_COMPATIBILITY_ALIAS", + "rewrite_path": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "remove_after": "2026-10-11" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", @@ -4273,6 +4289,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", @@ -6865,6 +6897,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_REST", + "method": "GET", + "path": "/v1/installations:listAvailable", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "rest", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_REST", "method": "GET", diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 50810c25a..ae72c9196 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -89,6 +89,9 @@ { "name": "ResourceFollowService" }, + { + "name": "SolutionEntitlementService" + }, { "name": "SolutionRegistryService" }, @@ -1845,6 +1848,52 @@ ] } }, + "/v1/installations:listAvailable": { + "get": { + "summary": "ListAvailableSolutions is the catalogue: the solution targets an\nadministrator may install right now.", + "description": "It answers from ACCEPTED applied state — a live target whose binding's\nnewest APPLIED generation is a present one — and deliberately not from the\ndiagnostic ListSolutionHostBindings, which also reports desired generations\nthat were refused. The distinction is the point: a refused generation is\nsomething an operator must see and something an administrator must not be\nable to consent to, because consenting to a release this host never admitted\nrecords authority over a presence that does not exist.\n\nIt carries the route alias, which the installation record deliberately does\nnot: here the alias is a display and routing fact read from the target in\nthe same statement, not an identity anything joins on.", + "operationId": "InstallationService_ListAvailableSolutions", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ListAvailableSolutionsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "orgId", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "pageSize", + "description": "Page bound. 0 -\u003e server default; capped for the same reason the installation\nlisting is capped.", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "pageToken", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "InstallationService" + ] + } + }, "/v1/invitations": { "get": { "operationId": "InvitationService_ListInvitations", @@ -6788,6 +6837,47 @@ }, "description": "AuthorizationCodeGrant redeems a code issued by IssueClientAuthorizationCode.\nredirect_uri must be the one the code was issued for, and code_verifier the\npreimage of its challenge." }, + "v1AvailableSolution": { + "type": "object", + "properties": { + "targetId": { + "type": "string", + "description": "The immutable target identity an install names. Never reused." + }, + "bindingId": { + "type": "string", + "description": "The binding whose presence this target records. Carried so an operator can\ncorrelate a catalogue row with delivery; an installation does not join on it,\nbecause one binding may have several targets over time." + }, + "routeAlias": { + "type": "string", + "description": "The route alias the applied generation claims — `/solutions/\u003calias\u003e/*`. A\nlater generation may move it, so it is never an identity." + }, + "releasePublisher": { + "type": "string", + "description": "The applied release: publisher, name, version." + }, + "releaseName": { + "type": "string" + }, + "releaseVersion": { + "type": "string" + }, + "openedGeneration": { + "type": "string", + "format": "uint64", + "description": "The generation that opened this target, and the newest applied generation.\nEqual until a later generation is applied for the same presence." + }, + "appliedGeneration": { + "type": "string", + "format": "uint64" + }, + "installed": { + "type": "boolean", + "description": "True when this organization already has an ACTIVE installation of this\ntarget. A catalogue shows it as installed rather than offering it twice; the\nunique index over (org_id, target_id) would refuse the second install anyway,\nand an administrator should not have to discover that by being refused." + } + }, + "description": "AvailableSolution is one installable solution target: its immutable identity,\nthe release the host currently has applied for it, and where it is routed." + }, "v1BeginOAuthRequest": { "type": "object", "properties": { @@ -8221,9 +8311,6 @@ "type": "string", "description": "Canonical \"publisher/name:version\" of the solution's agent. The agent\nPrincipal is created idempotently per version." }, - "solutionIdentifier": { - "type": "string" - }, "displayName": { "type": "string", "description": "Human display name for the agent principal. Empty defaults to the identifier." @@ -8259,6 +8346,10 @@ "items": { "type": "string" } + }, + "targetId": { + "type": "string", + "description": "The solution target being installed, from ListAvailableSolutions. Required.\n\nAn installer chooses an identity, never an address. The target must be LIVE\nand ACCEPTED when the install lands: installing a withdrawn target would\nrecord consent to a presence that already ended, and installing a target\nwhose newest generation was refused would record consent to a release this\nhost never admitted." } } }, @@ -8274,9 +8365,6 @@ "agentPrincipalId": { "type": "string" }, - "solutionIdentifier": { - "type": "string" - }, "ownerPrincipalId": { "type": "string" }, @@ -8299,6 +8387,14 @@ "revokedAt": { "type": "string", "format": "date-time" + }, + "targetId": { + "type": "string", + "description": "The immutable solution target this installation was consented to: one\ncontinuous period of one binding's presence on this host. It is never\nreused, so a replacement binding — even one claiming the same route alias —\nis a different target and inherits nothing.\n\nDeliberately NOT accompanied by the route alias. The alias is a property of\nthe target that a later generation may move, and resolving it here would\nmean joining the host's presence state into a request-path listing — which\nwould require granting request traffic read over every organisation's\npresence to answer about one. A consumer that needs the alias asks\nListAvailableSolutions, which serves accepted applied state, and joins on\nthis id.\n\nEmpty only on a revoked historical row whose target could not be\nestablished at the cutover; an active installation always names one." + }, + "revokedReason": { + "type": "string", + "description": "Why a revoked installation was revoked, when it was not an administrator\nuninstalling: the installation-identity cutover, or a tenancy narrowing that\nwithdrew this organisation. Empty on an active row." } }, "description": "Installation is one solution's bound identity in an org. Server-assigned ids;\nthe browser cannot attest to tenancy, the agent principal, or the scope node." @@ -8972,6 +9068,21 @@ } } }, + "v1ListAvailableSolutionsResponse": { + "type": "object", + "properties": { + "solutions": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1AvailableSolution" + } + }, + "nextPageToken": { + "type": "string" + } + } + }, "v1ListCollectionAccessResponse": { "type": "object", "properties": { @@ -10597,13 +10708,46 @@ }, "contractVersion": { "type": "string" + } + }, + "description": "SolutionBackendBinding is the stored backend half: the upstream the gateway\nproxies to and the logical service alias it is addressed by." + }, + "v1SolutionDeclaredBinding": { + "type": "object", + "properties": { + "bindingId": { + "type": "string", + "description": "Stable ID of the deployment instance that declared this record. It is not\nthe solution id: a binding ID identifies one instance and may carry\ncharacters a path segment may not." }, - "leaseExpiresAt": { + "generation": { "type": "string", - "format": "date-time" + "format": "uint64", + "description": "The generation of that binding the host applied into this record." + }, + "release": { + "type": "string", + "description": "publisher/name@version of the applied generation." + }, + "targetId": { + "type": "string", + "description": "The immutable solution target this declaration opened — the identity an\ninstallation names. It is carried on the registration record so a consumer\nthat was asked about a ROUTE ALIAS can resolve it to the target that\ncurrently serves it and compare identities.\n\nThat resolution is the whole admission mechanism: a replacement binding\nwhich claimed a withdrawn alias resolves to its own target, which no\ninstallation of its predecessor names. A record with no declaration carries\nno target and is therefore admissible to nobody, which is the fail-closed\nanswer and the direction of the cutover." + }, + "kind": { + "$ref": "#/definitions/v1SolutionDeclaredKind", + "description": "What the declaration declared the presence of. It decides which routing\nsurface serves this record, so it is carried rather than inferred: a host\nthat read the kind off the shape of a record — a publisher prefix, an alias\nconvention — would be guessing at exactly the question that separates a\nsurface with per-viewer admission from one without." } }, - "description": "SolutionBackendBinding is the stored backend half: the upstream the gateway\nproxies to and the logical service alias it is addressed by." + "description": "SolutionDeclaredBinding identifies the applied declaration and target." + }, + "v1SolutionDeclaredKind": { + "type": "string", + "enum": [ + "SOLUTION_DECLARED_KIND_UNSPECIFIED", + "SOLUTION_DECLARED_KIND_SOLUTION", + "SOLUTION_DECLARED_KIND_MODULE" + ], + "default": "SOLUTION_DECLARED_KIND_UNSPECIFIED", + "description": "SolutionDeclaredKind is what a declaration declares the presence of.\n\nCore requires it on every presence document and refuses any other value, so\nevery admitted declaration has exactly one of the two. The host used to drop\nit on reconcile, which left the registry unable to say whether a record was a\nsolution or a module — and the two are routed on different surfaces with\ndifferent admission, so a host that cannot tell them apart must either serve\nboth from one surface or serve neither.\n\nUNSPECIFIED is not a third kind and is never a default. It is what a reader\ndecodes from a writer that did not set the field, and both routing surfaces\nrefuse it by name rather than picking the kind that happens to be more\npermissive.\n\n - SOLUTION_DECLARED_KIND_SOLUTION: A composed solution instance, routed at /solutions/\u003calias\u003e/* with\nper-viewer installation admission.\n - SOLUTION_DECLARED_KIND_MODULE: One module instance, routed at /v1/\u003calias\u003e/* with the ordinary\nauthenticated pipeline." }, "v1SolutionFrontendBinding": { "type": "object", @@ -10617,13 +10761,9 @@ }, "contractVersion": { "type": "string" - }, - "leaseExpiresAt": { - "type": "string", - "format": "date-time" } }, - "description": "SolutionFrontendBinding is the stored frontend half. manifest is the document\nthe frontend validated before handing it over; this service persists it\nverbatim and never reinterprets it, so the host keeps sole ownership of what\na manifest may contain." + "description": "SolutionFrontendBinding is the stored frontend half. manifest is the document\nthe frontend validates while reading the projection; accounts stores it\nverbatim and keeps presence authority separate from runtime compatibility." }, "v1SolutionRegistration": { "type": "object", @@ -10633,7 +10773,7 @@ }, "publisher": { "type": "string", - "description": "Owner of record. First claim binds it; a registration naming a different\npublisher is refused rather than overwriting." + "description": "Canonical publisher attribution of the declared record." }, "revision": { "type": "string", @@ -10656,9 +10796,9 @@ "type": "string", "format": "date-time" }, - "runtimeBoundary": { - "type": "string", - "description": "The seed a solution's runtime boundary is derived from (issue #1015).\n\nALWAYS EMPTY on the wire, on every response, deliberately. The seed is\nassigned by this service when the record is created and never again, and it\nis the only input to the per-organization boundary every Work Context\nminted for that solution is sealed under — so it is what stands between one\nsolution's runs and another's. No registrant needs it: accounts derives and\nseals the boundary from the credential the solution already presents.\n\nThe field is kept rather than removed so this promise is testable: a change\nthat starts populating it fails a test instead of shipping." + "declared": { + "$ref": "#/definitions/v1SolutionDeclaredBinding", + "description": "The binding and immutable target that declared this record." } }, "description": "SolutionRegistration is the canonical record. revision is drawn from one\nregistry-wide sequence, so revisions are comparable across records and a\nconsumer can order two snapshots without a wall clock." @@ -10669,12 +10809,11 @@ "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED", "SOLUTION_REGISTRATION_STATUS_ACTIVE", "SOLUTION_REGISTRATION_STATUS_PENDING", - "SOLUTION_REGISTRATION_STATUS_EXPIRED", "SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE", "SOLUTION_REGISTRATION_STATUS_TOMBSTONED" ], "default": "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED", - "description": "SolutionRegistrationStatus is derived at read time from the stored record; it\nis never a stored column. It is what lets an operator tell a registration\nthat was never completed from one whose publisher stopped renewing, and both\nfrom one that was deliberately removed.\n\n - SOLUTION_REGISTRATION_STATUS_ACTIVE: Both halves are present, their leases are live, and their declared contract\nversions agree. This is the only status either surface serves.\n - SOLUTION_REGISTRATION_STATUS_PENDING: A half has never registered. The record is durable but incomplete, so no\npage advertises it and the gateway has nothing to route to.\n - SOLUTION_REGISTRATION_STATUS_EXPIRED: Both halves registered, but at least one lease lapsed: the deployment that\nowns it stopped renewing. The intent survives; the endpoint is not live.\n - SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE: The two halves declare different contract versions, so activating them\ntogether would serve a page against a backend it was not built for.\n - SOLUTION_REGISTRATION_STATUS_TOMBSTONED: The registration was deregistered. The row survives as a tombstone so a\ndelayed heartbeat from a retiring deployment cannot recreate it." + "description": "SolutionRegistrationStatus is derived from declared state and complete,\ncompatible frontend/backend observations, never from an expiring lease.\n\n - SOLUTION_REGISTRATION_STATUS_ACTIVE: Both halves are present and their declared contract\nversions agree. This is the only status either surface serves.\n - SOLUTION_REGISTRATION_STATUS_PENDING: A half has not been observed. The record is durable but incomplete, so no\npage advertises it and the gateway has nothing to route to.\n - SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE: The two halves declare different contract versions, so activating them\ntogether would serve a page against a backend it was not built for.\n - SOLUTION_REGISTRATION_STATUS_TOMBSTONED: An applied removal keeps the declaration as a tombstone." }, "v1StartChildSessionWorkContextRequest": { "type": "object", diff --git a/module/services/accounts/generated/rest-surface.json b/module/services/accounts/generated/rest-surface.json index 1e16000cd..57c543e4d 100644 --- a/module/services/accounts/generated/rest-surface.json +++ b/module/services/accounts/generated/rest-surface.json @@ -565,6 +565,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_REST", + "method": "GET", + "path": "/v1/installations:listAvailable", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "rest", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_REST", "method": "GET", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index e494d6dcd..5495166c3 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -168,6 +168,8 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -370,13 +372,19 @@ "/saas.accounts.v1.SSOAdminService/StartSetup" ] }, + { + "name": "SolutionEntitlementService", + "full_name": "saas.accounts.v1.SolutionEntitlementService", + "procedures": [ + "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" + ] + }, { "name": "SolutionRegistryService", "full_name": "saas.accounts.v1.SolutionRegistryService", "procedures": [ - "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", - "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", - "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" + "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", + "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" ] }, { @@ -4353,6 +4361,106 @@ "service": "accounts" } }, + { + "procedure": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "service": "InstallationService", + "full_service": "saas.accounts.v1.InstallationService", + "method": "ListAvailableSolutions", + "input_type": "saas.accounts.v1.ListAvailableSolutionsRequest", + "output_type": "saas.accounts.v1.ListAvailableSolutionsResponse", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "saas/accounts/v1/installations.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT", + "PROTOCOL_REST" + ], + "http_bindings": [ + { + "method": "GET", + "path": "/v1/installations:listAvailable", + "body": "", + "response_body": "" + } + ], + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "permissions": [], + "scopes": [], + "resource_bindings": [ + { + "request_field": "org_id", + "target": "RESOURCE_TARGET_ORGANIZATION", + "lookup": "RESOURCE_LOOKUP_DIRECT_ID" + } + ], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_READ", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "org_admin", + "description": "List the solution targets an administrator may install: accepted applied presence, never a refused desired generation.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, + { + "procedure": "/saas.accounts.v1.InstallationService/ListInstallations", + "service": "InstallationService", + "full_service": "saas.accounts.v1.InstallationService", + "method": "ListInstallations", + "input_type": "saas.accounts.v1.ListInstallationsRequest", + "output_type": "saas.accounts.v1.ListInstallationsResponse", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "saas/accounts/v1/installations.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_INTERNAL", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "internal", + "description": "List one organization's installations with their live health.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, { "procedure": "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "service": "InstallationService", @@ -10838,16 +10946,16 @@ } }, { - "procedure": "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", - "service": "SolutionRegistryService", - "full_service": "saas.accounts.v1.SolutionRegistryService", - "method": "DeleteSolutionRegistration", - "input_type": "saas.accounts.v1.DeleteSolutionRegistrationRequest", - "output_type": "saas.accounts.v1.SolutionRegistration", + "procedure": "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements", + "service": "SolutionEntitlementService", + "full_service": "saas.accounts.v1.SolutionEntitlementService", + "method": "ListSolutionEntitlements", + "input_type": "saas.accounts.v1.ListSolutionEntitlementsRequest", + "output_type": "saas.accounts.v1.ListSolutionEntitlementsResponse", "client_streaming": false, "server_streaming": false, "deprecated": false, - "source_proto": "saas/accounts/v1/solution_registry_service.proto", + "source_proto": "saas/accounts/v1/solution_entitlements_service.proto", "protocols": [ "PROTOCOL_GRPC", "PROTOCOL_CONNECT" @@ -10861,10 +10969,8 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [ - "saas.solution.registration_deleted" - ], - "emission": "AUDIT_EMISSION_SUCCESS" + "events": [], + "emission": "AUDIT_EMISSION_NONE" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", @@ -10876,19 +10982,19 @@ "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" }, "policy_tier": "internal", - "description": "Deregister a solution and leave a tombstone that blocks resurrection.", + "description": "List the installed solutions a subject may use in one organization.", "owner": { "module": "saas-starter", "service": "accounts" } }, { - "procedure": "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", + "procedure": "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", "service": "SolutionRegistryService", "full_service": "saas.accounts.v1.SolutionRegistryService", - "method": "ListSolutionRegistrations", - "input_type": "saas.accounts.v1.ListSolutionRegistrationsRequest", - "output_type": "saas.accounts.v1.ListSolutionRegistrationsResponse", + "method": "ListSolutionHostBindings", + "input_type": "saas.accounts.v1.ListSolutionHostBindingsRequest", + "output_type": "saas.accounts.v1.ListSolutionHostBindingsResponse", "client_streaming": false, "server_streaming": false, "deprecated": false, @@ -10919,19 +11025,19 @@ "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" }, "policy_tier": "internal", - "description": "Read the solution registry snapshot a replica rebuilds its cache from.", + "description": "Read the declared solution bindings: desired, applied and why they differ.", "owner": { "module": "saas-starter", "service": "accounts" } }, { - "procedure": "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration", + "procedure": "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", "service": "SolutionRegistryService", "full_service": "saas.accounts.v1.SolutionRegistryService", - "method": "PutSolutionRegistration", - "input_type": "saas.accounts.v1.PutSolutionRegistrationRequest", - "output_type": "saas.accounts.v1.SolutionRegistration", + "method": "ListSolutionRegistrations", + "input_type": "saas.accounts.v1.ListSolutionRegistrationsRequest", + "output_type": "saas.accounts.v1.ListSolutionRegistrationsResponse", "client_streaming": false, "server_streaming": false, "deprecated": false, @@ -10949,10 +11055,8 @@ "resource_bindings": [], "mfa": "MFA_REQUIREMENT_NONE", "audit": { - "events": [ - "saas.solution.registration_updated" - ], - "emission": "AUDIT_EMISSION_SUCCESS" + "events": [], + "emission": "AUDIT_EMISSION_NONE" }, "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", @@ -10964,7 +11068,7 @@ "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" }, "policy_tier": "internal", - "description": "Write or renew one half of a solution's durable runtime registration.", + "description": "Read the solution registry snapshot a replica rebuilds its cache from.", "owner": { "module": "saas-starter", "service": "accounts" diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index a020ff62e..5528103c3 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -777,6 +777,47 @@ }, "type": "object" }, + "v1AvailableSolution": { + "description": "AvailableSolution is one installable solution target: its immutable identity,\nthe release the host currently has applied for it, and where it is routed.", + "properties": { + "appliedGeneration": { + "format": "uint64", + "type": "string" + }, + "bindingId": { + "description": "The binding whose presence this target records. Carried so an operator can\ncorrelate a catalogue row with delivery; an installation does not join on it,\nbecause one binding may have several targets over time.", + "type": "string" + }, + "installed": { + "description": "True when this organization already has an ACTIVE installation of this\ntarget. A catalogue shows it as installed rather than offering it twice; the\nunique index over (org_id, target_id) would refuse the second install anyway,\nand an administrator should not have to discover that by being refused.", + "type": "boolean" + }, + "openedGeneration": { + "description": "The generation that opened this target, and the newest applied generation.\nEqual until a later generation is applied for the same presence.", + "format": "uint64", + "type": "string" + }, + "releaseName": { + "type": "string" + }, + "releasePublisher": { + "description": "The applied release: publisher, name, version.", + "type": "string" + }, + "releaseVersion": { + "type": "string" + }, + "routeAlias": { + "description": "The route alias the applied generation claims — `/solutions//*`. A\nlater generation may move it, so it is never an identity.", + "type": "string" + }, + "targetId": { + "description": "The immutable target identity an install names. Never reused.", + "type": "string" + } + }, + "type": "object" + }, "v1BeginOAuthRequest": { "description": "BeginOAuthRequest is the FE's first hop on the authorization-code\nflow. Server returns a server-signed `state` token bound to\n(provider, redirect_uri); the FE embeds it in the provider's\nauthorize URL alongside its own PKCE code_challenge. Authenticate\nlater verifies the state before exchanging the code.", "properties": { @@ -2245,7 +2286,8 @@ "description": "Human-readable display label for the solution's authority-root scope node\n(kind='solution'). The node's ltree path is derived server-side, not this.", "type": "string" }, - "solutionIdentifier": { + "targetId": { + "description": "The solution target being installed, from ListAvailableSolutions. Required.\n\nAn installer chooses an identity, never an address. The target must be LIVE\nand ACCEPTED when the install lands: installing a withdrawn target would\nrecord consent to a presence that already ended, and installing a target\nwhose newest generation was refused would record consent to a release this\nhost never admitted.", "type": "string" } }, @@ -2280,14 +2322,19 @@ "format": "date-time", "type": "string" }, - "rootScopeNodeId": { + "revokedReason": { + "description": "Why a revoked installation was revoked, when it was not an administrator\nuninstalling: the installation-identity cutover, or a tenancy narrowing that\nwithdrew this organisation. Empty on an active row.", "type": "string" }, - "solutionIdentifier": { + "rootScopeNodeId": { "type": "string" }, "status": { "$ref": "#/definitions/v1InstallationStatus" + }, + "targetId": { + "description": "The immutable solution target this installation was consented to: one\ncontinuous period of one binding's presence on this host. It is never\nreused, so a replacement binding — even one claiming the same route alias —\nis a different target and inherits nothing.\n\nDeliberately NOT accompanied by the route alias. The alias is a property of\nthe target that a later generation may move, and resolving it here would\nmean joining the host's presence state into a request-path listing — which\nwould require granting request traffic read over every organisation's\npresence to answer about one. A consumer that needs the alias asks\nListAvailableSolutions, which serves accepted applied state, and joins on\nthis id.\n\nEmpty only on a revoked historical row whose target could not be\nestablished at the cutover; an active installation always names one.", + "type": "string" } }, "type": "object" @@ -2961,6 +3008,21 @@ }, "type": "object" }, + "v1ListAvailableSolutionsResponse": { + "properties": { + "nextPageToken": { + "type": "string" + }, + "solutions": { + "items": { + "$ref": "#/definitions/v1AvailableSolution", + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, "v1ListCollectionAccessResponse": { "properties": { "collections": { @@ -4577,10 +4639,6 @@ "contractVersion": { "type": "string" }, - "leaseExpiresAt": { - "format": "date-time", - "type": "string" - }, "revision": { "format": "int64", "type": "string" @@ -4594,14 +4652,47 @@ }, "type": "object" }, - "v1SolutionFrontendBinding": { - "description": "SolutionFrontendBinding is the stored frontend half. manifest is the document\nthe frontend validated before handing it over; this service persists it\nverbatim and never reinterprets it, so the host keeps sole ownership of what\na manifest may contain.", + "v1SolutionDeclaredBinding": { + "description": "SolutionDeclaredBinding identifies the applied declaration and target.", "properties": { - "contractVersion": { + "bindingId": { + "description": "Stable ID of the deployment instance that declared this record. It is not\nthe solution id: a binding ID identifies one instance and may carry\ncharacters a path segment may not.", "type": "string" }, - "leaseExpiresAt": { - "format": "date-time", + "generation": { + "description": "The generation of that binding the host applied into this record.", + "format": "uint64", + "type": "string" + }, + "kind": { + "$ref": "#/definitions/v1SolutionDeclaredKind", + "description": "What the declaration declared the presence of. It decides which routing\nsurface serves this record, so it is carried rather than inferred: a host\nthat read the kind off the shape of a record — a publisher prefix, an alias\nconvention — would be guessing at exactly the question that separates a\nsurface with per-viewer admission from one without." + }, + "release": { + "description": "publisher/name@version of the applied generation.", + "type": "string" + }, + "targetId": { + "description": "The immutable solution target this declaration opened — the identity an\ninstallation names. It is carried on the registration record so a consumer\nthat was asked about a ROUTE ALIAS can resolve it to the target that\ncurrently serves it and compare identities.\n\nThat resolution is the whole admission mechanism: a replacement binding\nwhich claimed a withdrawn alias resolves to its own target, which no\ninstallation of its predecessor names. A record with no declaration carries\nno target and is therefore admissible to nobody, which is the fail-closed\nanswer and the direction of the cutover.", + "type": "string" + } + }, + "type": "object" + }, + "v1SolutionDeclaredKind": { + "default": "SOLUTION_DECLARED_KIND_UNSPECIFIED", + "description": "SolutionDeclaredKind is what a declaration declares the presence of.\n\nCore requires it on every presence document and refuses any other value, so\nevery admitted declaration has exactly one of the two. The host used to drop\nit on reconcile, which left the registry unable to say whether a record was a\nsolution or a module — and the two are routed on different surfaces with\ndifferent admission, so a host that cannot tell them apart must either serve\nboth from one surface or serve neither.\n\nUNSPECIFIED is not a third kind and is never a default. It is what a reader\ndecodes from a writer that did not set the field, and both routing surfaces\nrefuse it by name rather than picking the kind that happens to be more\npermissive.\n\n - SOLUTION_DECLARED_KIND_SOLUTION: A composed solution instance, routed at /solutions//* with\nper-viewer installation admission.\n - SOLUTION_DECLARED_KIND_MODULE: One module instance, routed at /v1//* with the ordinary\nauthenticated pipeline.", + "enum": [ + "SOLUTION_DECLARED_KIND_UNSPECIFIED", + "SOLUTION_DECLARED_KIND_SOLUTION", + "SOLUTION_DECLARED_KIND_MODULE" + ], + "type": "string" + }, + "v1SolutionFrontendBinding": { + "description": "SolutionFrontendBinding is the stored frontend half. manifest is the document\nthe frontend validates while reading the projection; accounts stores it\nverbatim and keeps presence authority separate from runtime compatibility.", + "properties": { + "contractVersion": { "type": "string" }, "manifest": { @@ -4620,21 +4711,21 @@ "backend": { "$ref": "#/definitions/v1SolutionBackendBinding" }, + "declared": { + "$ref": "#/definitions/v1SolutionDeclaredBinding", + "description": "The binding and immutable target that declared this record." + }, "frontend": { "$ref": "#/definitions/v1SolutionFrontendBinding" }, "publisher": { - "description": "Owner of record. First claim binds it; a registration naming a different\npublisher is refused rather than overwriting.", + "description": "Canonical publisher attribution of the declared record.", "type": "string" }, "revision": { "format": "int64", "type": "string" }, - "runtimeBoundary": { - "description": "The seed a solution's runtime boundary is derived from (issue #1015).\n\nALWAYS EMPTY on the wire, on every response, deliberately. The seed is\nassigned by this service when the record is created and never again, and it\nis the only input to the per-organization boundary every Work Context\nminted for that solution is sealed under — so it is what stands between one\nsolution's runs and another's. No registrant needs it: accounts derives and\nseals the boundary from the credential the solution already presents.\n\nThe field is kept rather than removed so this promise is testable: a change\nthat starts populating it fails a test instead of shipping.", - "type": "string" - }, "solutionId": { "type": "string" }, @@ -4654,12 +4745,11 @@ }, "v1SolutionRegistrationStatus": { "default": "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED", - "description": "SolutionRegistrationStatus is derived at read time from the stored record; it\nis never a stored column. It is what lets an operator tell a registration\nthat was never completed from one whose publisher stopped renewing, and both\nfrom one that was deliberately removed.\n\n - SOLUTION_REGISTRATION_STATUS_ACTIVE: Both halves are present, their leases are live, and their declared contract\nversions agree. This is the only status either surface serves.\n - SOLUTION_REGISTRATION_STATUS_PENDING: A half has never registered. The record is durable but incomplete, so no\npage advertises it and the gateway has nothing to route to.\n - SOLUTION_REGISTRATION_STATUS_EXPIRED: Both halves registered, but at least one lease lapsed: the deployment that\nowns it stopped renewing. The intent survives; the endpoint is not live.\n - SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE: The two halves declare different contract versions, so activating them\ntogether would serve a page against a backend it was not built for.\n - SOLUTION_REGISTRATION_STATUS_TOMBSTONED: The registration was deregistered. The row survives as a tombstone so a\ndelayed heartbeat from a retiring deployment cannot recreate it.", + "description": "SolutionRegistrationStatus is derived from declared state and complete,\ncompatible frontend/backend observations, never from an expiring lease.\n\n - SOLUTION_REGISTRATION_STATUS_ACTIVE: Both halves are present and their declared contract\nversions agree. This is the only status either surface serves.\n - SOLUTION_REGISTRATION_STATUS_PENDING: A half has not been observed. The record is durable but incomplete, so no\npage advertises it and the gateway has nothing to route to.\n - SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE: The two halves declare different contract versions, so activating them\ntogether would serve a page against a backend it was not built for.\n - SOLUTION_REGISTRATION_STATUS_TOMBSTONED: An applied removal keeps the declaration as a tombstone.", "enum": [ "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED", "SOLUTION_REGISTRATION_STATUS_ACTIVE", "SOLUTION_REGISTRATION_STATUS_PENDING", - "SOLUTION_REGISTRATION_STATUS_EXPIRED", "SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE", "SOLUTION_REGISTRATION_STATUS_TOMBSTONED" ], @@ -7274,6 +7364,52 @@ ] } }, + "/v1/installations:listAvailable": { + "get": { + "description": "It answers from ACCEPTED applied state — a live target whose binding's\nnewest APPLIED generation is a present one — and deliberately not from the\ndiagnostic ListSolutionHostBindings, which also reports desired generations\nthat were refused. The distinction is the point: a refused generation is\nsomething an operator must see and something an administrator must not be\nable to consent to, because consenting to a release this host never admitted\nrecords authority over a presence that does not exist.\n\nIt carries the route alias, which the installation record deliberately does\nnot: here the alias is a display and routing fact read from the target in\nthe same statement, not an identity anything joins on.", + "operationId": "InstallationService_ListAvailableSolutions", + "parameters": [ + { + "in": "query", + "name": "orgId", + "required": false, + "type": "string" + }, + { + "description": "Page bound. 0 -> server default; capped for the same reason the installation\nlisting is capped.", + "format": "int32", + "in": "query", + "name": "pageSize", + "required": false, + "type": "integer" + }, + { + "in": "query", + "name": "pageToken", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ListAvailableSolutionsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "summary": "ListAvailableSolutions is the catalogue: the solution targets an\nadministrator may install right now.", + "tags": [ + "InstallationService" + ] + } + }, "/v1/invitations": { "get": { "operationId": "InvitationService_ListInvitations", @@ -11485,6 +11621,9 @@ { "name": "ResourceFollowService" }, + { + "name": "SolutionEntitlementService" + }, { "name": "SolutionRegistryService" }, diff --git a/module/services/accounts/proto/buf.yaml b/module/services/accounts/proto/buf.yaml index 4a7e0e3cf..6cfad5207 100644 --- a/module/services/accounts/proto/buf.yaml +++ b/module/services/accounts/proto/buf.yaml @@ -44,6 +44,25 @@ breaking: # audit_export.proto is deleted; waive only the file-deletion rule for it. FILE_NO_DELETE: - saas/accounts/v1/audit_export.proto + # The solution-registration cold cutover (#952) deletes the whole + # runtime-asserted presence surface: a solution's presence is now DELIVERED + # by its declaration, so the writer RPCs a deployment used to heartbeat + # through, and the request/response and observation messages behind them, + # have no caller left. Legacy means delete, not deprecate — a retained + # writer is a second way to assert presence, which is the thing the cutover + # removes. Migration 26 drops the matching columns in the same change, so + # there is no window in which the old surface could be served. + # + # Each waiver below is scoped to the single rule the deletion trips. buf + # ignores are path-scoped, so a file is the narrowest expressible scope; the + # comments record what inside each file is intended to go. Every other + # breaking rule still guards these files, and the deletions are listed one + # by one in the pull request body. + ENUM_VALUE_NO_DELETE: + # SolutionRegistrationStatus.SOLUTION_REGISTRATION_STATUS_EXPIRED (3): + # derived from a lapsed lease, and there is no lease. Reserved in the + # proto so the number is never reused. + - saas/accounts/v1/solution_registry.proto # buf ignores are path-scoped, so neither entry can be narrowed to the single # message — the comments record the intended scope. MESSAGE_NO_DELETE: @@ -62,6 +81,11 @@ breaking: # Only AuditExportJob was dropped from jobs.proto. PrivacyExportJob's fields, # and every other rule, stay guarded. - saas/exports/v1/jobs.proto + # PutSolutionRegistrationRequest and DeleteSolutionRegistrationRequest are + # the deleted writer RPCs' messages; SolutionFrontendRegistration and + # SolutionBackendRegistration are the observations a deployment used to + # submit. All four belong to the removed writer surface (#952). + - saas/accounts/v1/solution_registry.proto # ListMyAccessibleScopes moved from PermissionService to the new # AccessibleScopeService in accessible_scopes.proto (issue #577), which changes # its Connect/gRPC procedure path. Its REST route (GET /v1/accessible-scopes) is @@ -69,6 +93,11 @@ breaking: # still fail on any RPC that disappears without the catalogs moving with it. RPC_NO_DELETE: - saas/accounts/v1/authorization.proto + # PutSolutionRegistration and DeleteSolutionRegistration: the registry is + # read-only to the wire now, written only by reconciling the declaration + # (#952). No replacement RPC takes their place, so nothing moved — the + # capability is gone. + - saas/accounts/v1/solution_registry_service.proto # target_collection (a free-text string that was neither resolvable nor # grantable) is replaced by a scope-node boundary binding across the three # datasource messages (issue #473). Waive only field deletion for this file; @@ -78,6 +107,21 @@ breaking: # nest) is replaced by a server-derived, UUID-labelled root node (issue #480). # Waive only field deletion for this file; every other breaking rule still # guards it. + # + # The FILE category flags a field deletion even when the number and name are + # reserved — reservation is honoured by FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED + # in the WIRE categories, which this module does not use. Every field below + # is reserved in its proto regardless: the waiver is what makes CI pass, the + # reservation is what stops the number being reused. FIELD_NO_DELETE: - saas/accounts/v1/datasource.proto - saas/accounts/v1/installations.proto + # SolutionFrontendBinding.lease_expires_at (4), + # SolutionBackendBinding.lease_expires_at (5) — presence is delivered, so + # nothing renews a lease — and SolutionRegistration.runtime_boundary (9), + # which is sealed into the Work Context instead of returned on a readable + # record (#952). + - saas/accounts/v1/solution_registry.proto + # ModuleCurrentInstallationResponse.solution_identifier (3): a reusable + # route alias, replaced by target_id and binding_id, which are identities. + - saas/accounts/v1/module_capabilities.proto diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index de8a54b3e..2fe42d2ab 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -51,13 +51,42 @@ message Installation { string id = 1 [(buf.validate.field).string.uuid = true]; string org_id = 2 [(buf.validate.field).string.uuid = true]; string agent_principal_id = 3 [(buf.validate.field).string.uuid = true]; - string solution_identifier = 4; + // Was `solution_identifier`: the free-text route alias. It was the join key + // every reader used, and because an alias is deliberately reusable by a later + // binding, an installation that named one inherited whatever took it next. + // Replaced by target_id below. Reserved rather than reused so no consumer can + // read an alias out of a field that now carries an identity. + reserved 4; + reserved "solution_identifier"; string owner_principal_id = 5 [(buf.validate.field).string.uuid = true]; repeated string co_owner_principal_ids = 6; string root_scope_node_id = 7 [(buf.validate.field).string.uuid = true]; InstallationStatus status = 8; google.protobuf.Timestamp created_at = 9; optional google.protobuf.Timestamp revoked_at = 10; + // The immutable solution target this installation was consented to: one + // continuous period of one binding's presence on this host. It is never + // reused, so a replacement binding — even one claiming the same route alias — + // is a different target and inherits nothing. + // + // Deliberately NOT accompanied by the route alias. The alias is a property of + // the target that a later generation may move, and resolving it here would + // mean joining the host's presence state into a request-path listing — which + // would require granting request traffic read over every organisation's + // presence to answer about one. A consumer that needs the alias asks + // ListAvailableSolutions, which serves accepted applied state, and joins on + // this id. + // + // Empty only on a revoked historical row whose target could not be + // established at the cutover; an active installation always names one. + string target_id = 11 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; + // Why a revoked installation was revoked, when it was not an administrator + // uninstalling: the installation-identity cutover, or a tenancy narrowing that + // withdrew this organisation. Empty on an active row. + string revoked_reason = 12 [(buf.validate.field).string.max_len = 512]; } message InstallSolutionRequest { @@ -68,10 +97,11 @@ message InstallSolutionRequest { (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 512 ]; - string solution_identifier = 3 [ - (buf.validate.field).string.min_len = 1, - (buf.validate.field).string.max_len = 512 - ]; + // Was `solution_identifier`: the route alias the installer typed. An alias is + // reusable, so installing by alias is installing whatever holds it at the + // moment the write lands — and whatever takes it later. Replaced by target_id. + reserved 3; + reserved "solution_identifier"; // Human display name for the agent principal. Empty defaults to the identifier. string display_name = 4 [(buf.validate.field).string.max_len = 512]; // The authority-root scope path is derived server-side from the created node id @@ -109,6 +139,14 @@ message InstallSolutionRequest { (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 128 ]; + // The solution target being installed, from ListAvailableSolutions. Required. + // + // An installer chooses an identity, never an address. The target must be LIVE + // and ACCEPTED when the install lands: installing a withdrawn target would + // record consent to a presence that already ended, and installing a target + // whose newest generation was refused would record consent to a release this + // host never admitted. + string target_id = 12 [(buf.validate.field).string.uuid = true]; } message UninstallSolutionRequest { @@ -139,6 +177,42 @@ message GetInstallationResponse { InstallationHealth health = 2; } +// ListInstallationsRequest asks what one organization has installed. Every other +// RPC on this service names a single installation, so until this existed nothing +// could answer "what has this org installed?" — which is the authority read a +// per-organization solution projection is built on (issue #949). +message ListInstallationsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // Restrict to one lifecycle status. UNSPECIFIED returns every status, so a + // caller that must see only live installs asks for ACTIVE explicitly rather + // than leaning on a default that would silently widen if the enum grew. + InstallationStatus status = 2; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +// InstallationSummary is one row of the listing: the installation, plus the health +// resolved live beside it exactly as GetInstallation resolves it for one. Health +// travels with the row because a consumer that renders an installed solution as +// unavailable would otherwise need one GetInstallation per installation. +message InstallationSummary { + Installation installation = 1; + InstallationHealth health = 2; +} + +message ListInstallationsResponse { + repeated InstallationSummary installations = 1; + // Set when more installations remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + // InstallationService composes and administers installations. The headless mint // that turns an installation into a short-lived Work Context is // WorkContextService.StartInstallationTask. @@ -255,4 +329,107 @@ service InstallationService { }; option (google.api.http) = {get: "/v1/installations/{installation_id}"}; } + + // ListInstallations enumerates one organization's installations with their live + // health. It is internal-tier on purpose: it takes the organization as a request + // field, so only a caller holding the cluster-internal credential may ask, and + // the tenant it asks about is the one the auth-gateway projected from a verified + // identity — never one a browser supplied. It sits beside + // PermissionService.ListAccessibleScopes for that reason: the two are read + // together to answer what a viewer's organization installed and what that viewer + // was granted. + rpc ListInstallations(ListInstallationsRequest) returns (ListInstallationsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } + + // ListAvailableSolutions is the catalogue: the solution targets an + // administrator may install right now. + // + // It answers from ACCEPTED applied state — a live target whose binding's + // newest APPLIED generation is a present one — and deliberately not from the + // diagnostic ListSolutionHostBindings, which also reports desired generations + // that were refused. The distinction is the point: a refused generation is + // something an operator must see and something an administrator must not be + // able to consent to, because consenting to a release this host never admitted + // records authority over a presence that does not exist. + // + // It carries the route alias, which the installation record deliberately does + // not: here the alias is a display and routing fact read from the target in + // the same statement, not an identity anything joins on. + rpc ListAvailableSolutions(ListAvailableSolutionsRequest) returns (ListAvailableSolutionsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_AUTHENTICATED + tenant: TENANT_REQUIREMENT_ORG_ADMIN + resource_bindings: { + request_field: "org_id" + target: RESOURCE_TARGET_ORGANIZATION + lookup: RESOURCE_LOOKUP_DIRECT_ID + } + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_STANDARD_READ + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + // Mounted under `/v1/installations`, a prefix the catalog already owns. + // A `/v1/solutions` route would have taken that prefix away from every + // composed module that may register one, which is a contract change with + // every downstream composer and not a side effect of adding a read. + option (google.api.http) = {get: "/v1/installations:listAvailable"}; + } +} + +// AvailableSolution is one installable solution target: its immutable identity, +// the release the host currently has applied for it, and where it is routed. +message AvailableSolution { + // The immutable target identity an install names. Never reused. + string target_id = 1 [(buf.validate.field).string.uuid = true]; + // The binding whose presence this target records. Carried so an operator can + // correlate a catalogue row with delivery; an installation does not join on it, + // because one binding may have several targets over time. + string binding_id = 2; + // The route alias the applied generation claims — `/solutions//*`. A + // later generation may move it, so it is never an identity. + string route_alias = 3; + // The applied release: publisher, name, version. + string release_publisher = 4; + string release_name = 5; + string release_version = 6; + // The generation that opened this target, and the newest applied generation. + // Equal until a later generation is applied for the same presence. + uint64 opened_generation = 7; + uint64 applied_generation = 8; + // True when this organization already has an ACTIVE installation of this + // target. A catalogue shows it as installed rather than offering it twice; the + // unique index over (org_id, target_id) would refuse the second install anyway, + // and an administrator should not have to discover that by being refused. + bool installed = 9; +} + +message ListAvailableSolutionsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped for the same reason the installation + // listing is capped. + int32 page_size = 2 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + string page_token = 3; +} + +message ListAvailableSolutionsResponse { + repeated AvailableSolution solutions = 1; + string next_page_token = 2; } diff --git a/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto b/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto index 31fcc30f2..17ea81343 100644 --- a/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto +++ b/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto @@ -960,7 +960,20 @@ message ModuleCurrentInstallationRequest { message ModuleCurrentInstallationResponse { string installation_id = 1; string tenant_id = 2; - string solution_identifier = 3; + // Was `solution_identifier`: the route alias. The host cannot answer it on a + // request path — the alias lives on the host's presence state, and resolving + // it here would mean joining every organisation's presence to answer about + // one — and it is not an identity in any case, because a later binding may + // take the same route. Reserved rather than reused so no caller reads an + // identity out of a field that carried an alias. + reserved 3; + reserved "solution_identifier"; + // The immutable target this installation was consented to: one continuous + // period of one binding's presence, never reused. + string target_id = 4 [(buf.validate.field).string.uuid = true]; + // The delivered presence binding whose period that target records. This is + // what an authority decision keys on; the alias is display only. + string binding_id = 5; } service ModuleCapabilitiesService { diff --git a/module/services/accounts/proto/saas/accounts/v1/solution_entitlements.proto b/module/services/accounts/proto/saas/accounts/v1/solution_entitlements.proto new file mode 100644 index 000000000..dc048f8ee --- /dev/null +++ b/module/services/accounts/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// ============================================================================ +// Per-viewer solution entitlement (issue #949) +// +// A viewer should see the solutions their organization installed and their teams +// were granted, and nothing else. Answering that needs two authority facts read +// together: what the organization has installed (installations) and which of +// those installations' authority-root scope nodes the viewer may act on (the +// grant + share union every scope decision resolves through). +// +// They are answered by ONE read, in one org transaction, rather than by joining +// two RPCs in a caller: read separately, a revoke landing between the two calls +// yields a menu entry whose grant is already gone, and the revision a consumer +// caches on would describe no state that ever existed. +// +// These messages live in their own file with deliberately minimal imports — the +// same reason solution_registry.proto does — so the auth-gateway can generate +// the same types from the same source proto without vendoring the policy +// options. The service that serves them is +// solution_entitlements_service.proto, which accounts alone needs. +// ============================================================================ + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +message SolutionEntitlement { + // Was `solution_identifier`: the route alias, which consumers joined against + // their registered set. An alias is reusable by a later binding, so that join + // admitted a replacement to the predecessor's entitlement. Replaced by + // target_id below. + reserved 1; + reserved "solution_identifier"; + string installation_id = 2 [(buf.validate.field).string.uuid = true]; + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + string root_scope_node_id = 3 [(buf.validate.field).string.uuid = true]; + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + bool healthy = 4; + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + string target_id = 5 [(buf.validate.field).string.uuid = true]; + // Deliberately no platform-administrator basis field. The scope union admits a + // platform super_admin for the action `read` alone, and using a solution is not + // a read, so that branch can never contribute an entitlement here — a field + // reporting it would be permanently false. A super_admin sees an organization's + // installations through GetInstallation / ListInstallations, not by appearing to + // be entitled to them. +} + +message ListSolutionEntitlementsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + string subject_id = 2 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +message ListSolutionEntitlementsResponse { + repeated SolutionEntitlement entitlements = 1; + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + +// Deliberately NOT here: a revision or fingerprint field. A consumer that caches +// a projection built from these rows keys it on a digest of the rows it actually +// received, which is exact by construction. A revision computed server-side would +// have to describe either one page or a set larger than the caller read, and the +// paginated case makes the two disagree. The ordering is deterministic (keyset on +// installation id), so a consumer's own digest is stable across reads that return +// the same entitlements. diff --git a/module/services/accounts/proto/saas/accounts/v1/solution_entitlements_service.proto b/module/services/accounts/proto/saas/accounts/v1/solution_entitlements_service.proto new file mode 100644 index 000000000..fa82633e1 --- /dev/null +++ b/module/services/accounts/proto/saas/accounts/v1/solution_entitlements_service.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "saas/accounts/v1/solution_entitlements.proto"; +import "saas/policy/v1/options.proto"; + +// The per-viewer solution entitlement service surface (issue #949). It is split +// from the messages in solution_entitlements.proto for the reason +// solution_registry_service.proto is split from its own: the auth-gateway is the +// only client and generates those messages without also vendoring the policy +// options this file needs. + +// SolutionEntitlementService answers what one viewer may use inside one +// organization. Internal-tier: it takes both the organization and the subject as +// request fields, so it is an authority oracle about an arbitrary principal and +// only a caller holding the cluster-internal credential may ask. The caller that +// does is the auth-gateway, naming the tenant and the viewer it projected from a +// verified identity — never values a browser supplied. +service SolutionEntitlementService { + // ListSolutionEntitlements enumerates the installed solutions a subject may + // use, reading the installation set and the scope grant + share union in one + // org transaction so the two can never describe different moments. + rpc ListSolutionEntitlements(ListSolutionEntitlementsRequest) returns (ListSolutionEntitlementsResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } +} diff --git a/module/services/accounts/proto/saas/accounts/v1/solution_registry.proto b/module/services/accounts/proto/saas/accounts/v1/solution_registry.proto index 3cc97e45b..08933023d 100644 --- a/module/services/accounts/proto/saas/accounts/v1/solution_registry.proto +++ b/module/services/accounts/proto/saas/accounts/v1/solution_registry.proto @@ -2,65 +2,47 @@ syntax = "proto3"; package saas.accounts.v1; -import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; -// ============================================================================ -// Durable solution registry (issue #534) -// -// A solution is an independently deployed component the host has no build-time -// knowledge of. It self-registers two halves at runtime: a frontend half (the -// Module-Federation manifest the host renders and navigates to) and a backend -// half (the upstream the gateway proxies /solutions//* to). Both used to be -// process-local maps, so a restart lost them, a registration behind a load -// balancer reached one replica only, and one half could be live while the other -// was missing. -// -// These messages live in their own file, with deliberately minimal imports, so -// the auth-gateway can generate the same types from the same source proto and -// call SolutionRegistryService (declared in solution_registry_service.proto) -// over the internal listener. Sharing the generated definition rather than -// restating the wire shape is what keeps the two sides from drifting. -// -// Two facts are deliberately distinct. Durable installation intent is the -// record existing and not being tombstoned; ephemeral endpoint liveness is the -// per-half lease, renewed by whoever registered that half. A lapsed lease -// suspends routing without discarding the registration, and the derived status -// says which of the two is at fault. -// ============================================================================ +// Read-only projection of the host's declared solution presence. The gateway +// shares these messages and reads the accounts internal listener. -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. enum SolutionRegistrationStatus { SOLUTION_REGISTRATION_STATUS_UNSPECIFIED = 0; - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SOLUTION_REGISTRATION_STATUS_PENDING = 2; - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; + // Was `SOLUTION_REGISTRATION_STATUS_EXPIRED`: both halves registered but a + // lease had lapsed. Presence is delivered, never heartbeat-asserted, so there + // is no lease to lapse and no status to derive from one. Reserved rather than + // reused: an old reader decoding 3 would read "expired" out of whatever + // meaning a later value gave the number. + reserved 3; + reserved "SOLUTION_REGISTRATION_STATUS_EXPIRED"; // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE = 4; - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; } // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. message SolutionFrontendBinding { int64 revision = 1; string manifest = 2; string contract_version = 3; - google.protobuf.Timestamp lease_expires_at = 4; + // Was `google.protobuf.Timestamp lease_expires_at`: the renewal path is + // deleted, so a column nothing renews was legacy. Reserved, not reused — a + // released number never takes another name or type. + reserved 4; + reserved "lease_expires_at"; } // SolutionBackendBinding is the stored backend half: the upstream the gateway @@ -70,7 +52,10 @@ message SolutionBackendBinding { string upstream = 2; string service_alias = 3; string contract_version = 4; - google.protobuf.Timestamp lease_expires_at = 5; + // Was `google.protobuf.Timestamp lease_expires_at`: see + // SolutionFrontendBinding above. Reserved, not reused. + reserved 5; + reserved "lease_expires_at"; } // SolutionRegistration is the canonical record. revision is drawn from one @@ -78,8 +63,7 @@ message SolutionBackendBinding { // consumer can order two snapshots without a wall clock. message SolutionRegistration { string solution_id = 1; - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. string publisher = 2; int64 revision = 3; SolutionRegistrationStatus status = 4; @@ -87,91 +71,20 @@ message SolutionRegistration { optional SolutionBackendBinding backend = 6; google.protobuf.Timestamp updated_at = 7; optional google.protobuf.Timestamp tombstoned_at = 8; - // The seed a solution's runtime boundary is derived from (issue #1015). + // Was `string runtime_boundary`: the opaque id every Work Context minted for + // this solution is sealed under. It is NOT a field a readable record carries + // — a boundary returned on a readable record is a capability leaking as a + // label — so it is deleted from the wire rather than renamed, and the host + // seals it into the capability instead. // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - string runtime_boundary = 9; -} - -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -message SolutionFrontendRegistration { - string manifest = 1 [(buf.validate.field).string = { - min_len: 2 - max_len: 262144 - }]; - string contract_version = 2 [(buf.validate.field).string.max_len = 128]; -} - -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -message SolutionBackendRegistration { - string upstream = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 2048 - uri: true - }]; - string service_alias = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - string contract_version = 3 [(buf.validate.field).string.max_len = 128]; -} - -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -message PutSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - pattern: "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$" - }]; - string publisher = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 256 - }]; - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - optional int64 expected_revision = 3 [(buf.validate.field).int64.gt = 0]; - // Requested liveness window for this half. The registrant renews within it. - uint32 lease_seconds = 4 [(buf.validate.field).uint32 = { - gte: 30 - lte: 3600 - }]; - oneof half { - option (buf.validate.oneof).required = true; - SolutionFrontendRegistration frontend = 5; - SolutionBackendRegistration backend = 6; - } -} - -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -message DeleteSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - optional int64 expected_revision = 2 [(buf.validate.field).int64.gt = 0]; + // Reserved rather than reused. A released field number never takes another + // name or type: a consumer still on the old schema reading 9 would decode a + // message where it expected a string. `declared` therefore took 10, which + // cost a digest move and is the price of not corrupting an old reader. + reserved 9; + reserved "runtime_boundary"; + // The binding and immutable target that declared this record. + optional SolutionDeclaredBinding declared = 10; } // ListSolutionRegistrationsRequest reads the whole registry. There is one @@ -189,3 +102,127 @@ message ListSolutionRegistrationsResponse { // report the same registry_revision have converged on the same state. int64 registry_revision = 2; } + +// ============================================================================ +// Declared solution presence (issue #952) +// +// A SolutionHostBinding is the document delivery hands this host: which solution +// runs here, at which generation. The host reconciles it into the registration +// record above. These messages are the read surface, and they keep the three +// facts apart on purpose — an operator's first question is which of them is +// wrong. +// ============================================================================ + +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +enum SolutionDeclaredKind { + SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SOLUTION_DECLARED_KIND_SOLUTION = 1; + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SOLUTION_DECLARED_KIND_MODULE = 2; +} + +// SolutionDeclaredBinding identifies the applied declaration and target. +message SolutionDeclaredBinding { + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + string binding_id = 1; + // The generation of that binding the host applied into this record. + uint64 generation = 2; + // publisher/name@version of the applied generation. + string release = 3; + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. + // + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + string target_id = 4; + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + SolutionDeclaredKind kind = 5; +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +message SolutionHostBindingGeneration { + uint64 generation = 1; + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + string digest = 2; + string document = 3; + google.protobuf.Timestamp at = 4; +} + +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +message SolutionHostBindingAppliedGeneration { + SolutionHostBindingGeneration generation = 1; + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + bool removed = 2; + // The route aliases the applied generation holds. A tombstone holds none. + repeated string routes = 3; + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + string solution_id = 4; + string release = 5; +} + +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +message SolutionHostBindingState { + string binding_id = 1; + string host_coordinate = 2; + string host_component = 3; + // The newest generation delivery has shown this host, whether or not it + // passed. + optional SolutionHostBindingGeneration desired = 4; + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + optional SolutionHostBindingAppliedGeneration applied = 5; + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + uint64 pending_generation = 6; + // Why the pending generation was not applied. Empty when nothing is pending. + string pending_reason = 7; + optional google.protobuf.Timestamp pending_since = 8; + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + optional SolutionRegistration registration = 9; + google.protobuf.Timestamp updated_at = 10; +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +message ListSolutionHostBindingsRequest {} + +message ListSolutionHostBindingsResponse { + repeated SolutionHostBindingState bindings = 1; +} diff --git a/module/services/accounts/proto/saas/accounts/v1/solution_registry_service.proto b/module/services/accounts/proto/saas/accounts/v1/solution_registry_service.proto index a86768d41..61b1a901d 100644 --- a/module/services/accounts/proto/saas/accounts/v1/solution_registry_service.proto +++ b/module/services/accounts/proto/saas/accounts/v1/solution_registry_service.proto @@ -5,48 +5,20 @@ package saas.accounts.v1; import "saas/accounts/v1/solution_registry.proto"; import "saas/policy/v1/options.proto"; -// The durable solution registry's service surface (issue #534). It is split -// from the messages in solution_registry.proto so the auth-gateway — the only -// client — can generate those messages without also vendoring the policy -// options this file needs. -// -// Every method is EXPOSURE_INTERNAL: the gateway reaches them over the accounts -// internal listener, and it brokers the frontend's half as well as its own, so -// the frontend holds no second connection to accounts. - -// SolutionRegistryService is the durable authority behind both self-registration -// surfaces. Every method is internal: the gateway is the only client, and it -// brokers the frontend's half as well as its own. +// Internal read service for declared solution state. The gateway is the client +// and brokers the registry projection to the frontend. service SolutionRegistryService { - // PutSolutionRegistration writes or renews one half of a solution's - // registration under compare-and-swap revision semantics. - rpc PutSolutionRegistration(PutSolutionRegistrationRequest) returns (SolutionRegistration) { + // ListSolutionHostBindings returns the declared bindings: what delivery has + // shown this host, what the host applied, and why a desired generation is not + // the applied one (issue #952). It is the answer to "is this solution missing, + // or declared and unhealthy?", which neither the registry snapshot nor a health + // probe can give on its own. + rpc ListSolutionHostBindings(ListSolutionHostBindingsRequest) returns (ListSolutionHostBindingsResponse) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_INTERNAL tenant: TENANT_REQUIREMENT_NONE mfa: MFA_REQUIREMENT_NONE - audit: { - events: "saas.solution.registration_updated" - emission: AUDIT_EMISSION_SUCCESS - } - idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN - rate_limit: RATE_LIMIT_CLASS_INTERNAL - request_sensitivity: SENSITIVITY_CONFIDENTIAL - response_sensitivity: SENSITIVITY_CONFIDENTIAL - platform_role: PLATFORM_ROLE_REQUIREMENT_NONE - }; - } - // DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - // a later heartbeat from the retired deployment cannot resurrect. - rpc DeleteSolutionRegistration(DeleteSolutionRegistrationRequest) returns (SolutionRegistration) { - option (saas.policy.v1.method_policy) = { - exposure: EXPOSURE_INTERNAL - tenant: TENANT_REQUIREMENT_NONE - mfa: MFA_REQUIREMENT_NONE - audit: { - events: "saas.solution.registration_deleted" - emission: AUDIT_EMISSION_SUCCESS - } + audit: {emission: AUDIT_EMISSION_NONE} idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN rate_limit: RATE_LIMIT_CLASS_INTERNAL request_sensitivity: SENSITIVITY_CONFIDENTIAL diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 60b27bbaa..fe6c830d3 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -48,10 +48,8 @@ endpoints: - name: authority api: grpc visibility: internal - allow-modules: ["*"] - name: connect visibility: internal - allow-modules: ["*"] - name: grpc - name: rest spec: diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index f09c76c4e..a4c788b67 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -1,115 +1,60 @@ # AGENTS.md — auth-gateway The single front door. Every request a consumer makes arrives here, gets an -ext_authz Check, and is proxied to a service — or to a **runtime-registered** -upstream it learned about with no rebuild. Read +ext_authz Check, and is proxied to a service — or to a declared solution +upstream from the durable registry. Read [../../GATEWAY_ROUTES.md](../../GATEWAY_ROUTES.md) for the generated route inventory and [../../SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md) -for the trust model behind the registration surfaces below. +for the trust model behind declared presence. Nothing here may name a specific solution or composed module: every seam is generic, and a registered target is data, never a branch. -## Solution upstream registration - -`POST /solutions/_register` (`code/gateway_solutions.go`) takes `{id, upstream}`; -**the gateway supplies the compare-and-swap revision it last saw**, not the -registrant. `GET /solutions/_registry` returns this replica's snapshot — id, -publisher, revision, and status (`active` / `pending` / `expired` / -`incompatible` / `tombstoned`), **never an upstream** — which is both what the -frontend rebuilds from and what an operator reads to tell those states apart. - -The gateway then proxies `/solutions/{id}/…` to the registered upstream, running -**the same ext_authz Check and identity-header discipline as catalog routes**. -Only the public `/assets` and `/.well-known` sub-paths are served -unauthenticated, and only for GET/HEAD. - -## Both registration halves take an owner-bound credential - -Neither the gateway half nor the frontend half is gated on the shared -cluster-internal token. The caller presents a signed, solution-bound -registration token in `X-Codefly-Solution-Registration`, obtained from `POST -/solutions/_registration-token` against the solution's own secret -(`SOLUTION_REGISTRATION_SECRETS` in the `federation` group, declared separately -from the module secrets). The credential names one solution id and one -publisher, so a holder can neither claim nor re-point another solution. - -## The mint is where a solution's boundary is decided - -A registered solution's runs are filed under one opaque **runtime boundary** -accounts assigned at registration (`../accounts/AGENTS.md`), and accounts seals -it into every Work Context it mints for that solution. It learns which solution -is asking from one place: the `X-Codefly-Solution-Id` and -`X-Codefly-Solution-Publisher` this gateway stamps on -`POST /saas.accounts.v1.WorkContextService/StartTask` (and its REST spelling) -after verifying the **same** solution-bound registration credential both -registration halves take. The publisher travels with the id because accounts -checks it against the publisher of record, so a secret re-provisioned to a -different publisher cannot mint the boundary of a registration it cannot write. -Nothing in the request body has a say, and the caller's own spelling of either -header is stripped like every other canonical identity header — without which -any authenticated viewer could mint under any solution's boundary by typing its -id. - -Three deliberate choices: - -- **Only that procedure.** The credential means nothing on any other route, so - what it widens is one mint rather than the whole surface. -- **A presented credential that does not verify is a 401**, not an ordinary - mint. Falling through would answer a forged, expired or malformed credential - with a capability under a different boundary — the one outcome worth being - loud about. A request presenting none is an ordinary mint and is untouched. -- **The `jti` is not burned here**, unlike on the registration mutations. That - guard stops a captured credential re-pointing a route after the registrant has - moved on; a mint steers no state a replay could reach, and a solution's - passthrough mints per audience and scope set, so burning it would force a - fresh credential exchange — an audited mint on accounts — several times per - page. The credential's five-minute life is the bound. - -No registration answer carries a boundary, to either half, and neither does the -`_registry` snapshot: accounts never sends one, because a solution has no use -for a value it never names. - -## Composed-module REST federation - -A composed module that serves its own `/v1//*` surface is federated -through `POST /modules/_register` (`code/gateway_modules.go`), and the gateway -proxies that prefix **once the generated catalog has no match**. The registrant -is the **consuming** backend — the solution that declares the module's API in -its `api.consumes` — not the module itself: it registers each consumed prefix at -the address it resolved for the consumed endpoint. Unlike solution registration -this is **not** gated on the shared internal token: the caller must present a -signed, prefix-bound registration token in `X-Codefly-Module-Registration`, so -a holder of the credential for one prefix cannot claim another. The handshake -is three calls: - -1. `POST /modules/_registration-token` on the auth-gateway, with the - cluster-internal token in `X-Codefly-Internal-Token` **and** the registration - secret for that prefix in `X-Codefly-Module-Secret`, body `{prefix}`. -2. The gateway brokers to accounts over the internal listener - (`ModuleCapabilitiesService/MintModuleRegistration`, `EXPOSURE_INTERNAL`, so - the generated mesh policy admits the gateway's service account and denies - everyone else). accounts compares the secret against the digest declared for - that prefix in the `federation` configuration group's - `MODULE_REGISTRATION_SECRETS` and, on a match, mints a 5-minute Ed25519 token - (`aud=module-registration`, `sub=module:`) with the key the gateway - already trusts through JWKS, emitting a `module.registration_minted` audit - event. **Unset means no module may federate.** -3. `POST /modules/_register` with that token and `{prefix, upstream}`. - -Composition provisions the pair: the SHA-256 digest into this host's -`federation` group, and the plaintext **to the consuming backend only** (the -Codefly CLI hands it over as `CODEFLY__MODULE_REGISTRATION_SECRETS`, a map -keyed by prefix, which the solution runtime spends). The consumed module itself -never receives a registration secret: it is given only its **identity** secret, -which it spends on the Work Context exchange below and which this host checks -against the separate `MODULE_IDENTITY_SECRETS` digest. Two secrets per prefix -is what lets this side tell a backend registering a prefix from that prefix's -own service principal. This host checks only that the secret presented for a -prefix matches its digest; it neither knows nor cares which process presents -it. The token is short-lived and fetched **per registration attempt**, not -cached across a gateway restart. Registration only adds a proxy target — every -proxied `/v1//*` request still runs the full ext_authz check. +## Reading declared solution upstreams + +`GET /solutions/_registry` returns this replica's read projection: identity, +publisher, revision, frontend manifest, declared target and status (`active`, +`pending`, `incompatible`, `tombstoned`). It never exposes the backend upstream. +The frontend reads it with the cluster-internal credential. + +The gateway proxies `/solutions/{id}/…` using the same verified identity and +header discipline as catalog routes, with per-viewer installation admission. +Only public `/assets` and `/.well-known` paths accept unauthenticated GET/HEAD. + +## Composed-module REST routes + +A composed module is reached at `/v1//*`, and the alias comes from the same +place a solution's does: the declared registry. The gateway holds **no +process-local module upstream registry** and nothing self-registers — main's +`/modules/_register`, where a module POSTed its own upstream and the gateway +believed it, is deleted. + +The **catalog always wins**: `handleDeclaredModule` is consulted only after the +generated and explicit catalogs found no route, so a declared alias can never +shadow one. An alias nothing declares falls through to the ordinary 404, which is +what keeps the surface from being a probe for which modules a deployment runs. An +alias the catalog *owns* is refused outright rather than half-served — the matcher +takes only the paths it matches, so serving the rest would split one prefix +between two authorities. + +Beyond that it is the **solution shape, with one deliberate difference**: the same +cache, the same single carried resolution, the same upstream URL policy, the same +guarded (resolve-revalidating) transport, the same tombstone rule, the same 120 s +revocation bound — and **no per-viewer installation admission**, because a module +is part of the composition rather than something an organisation installs. The +path is forwarded **unchanged** (a module owns its own `/v1/` surface), +where a solution's prefix is stripped. + +That asymmetry is why the registry carries `SolutionDeclaredBinding.kind` and why +neither surface guesses: routing a solution here would put its upstream behind no +installation check. Each surface requires its own kind positively and answers a +403 verdict for the other — `module is not declared on this host`, or `solution is +not declared on this host`. `SOLUTION_DECLARED_KIND_UNSPECIFIED` is refused by +**both**: it is what a reader decodes from a writer that did not set the field, not +a third kind, so such a record is served by neither surface rather than by the one +needing less authority. + +Module identity exchanges are described below; accounts owns their authority. ## A registered client calls without a proxy @@ -138,7 +83,7 @@ answer the way `gateway_solution_registry.go` caches the solution registry, and passes through whatever route matched it: a token naming a client must arrive from an origin that client registered, or it is refused before it reaches any upstream. A token issued to one client is therefore useless from another's - page, on catalog, solution, and federated-module routes alike. + page, on catalog, solution, and declared-module routes alike. - `X-Codefly-Public-Origin` — the WebAuthn relying party, the origin an OAuth start is validated against — is derived from that registration. The frontend's internal-token path still establishes it too; the registration is the stronger @@ -171,7 +116,7 @@ other drops. ## Brokering a module's Work Context -`POST /modules/_work-context` mirrors the registration exchange: the +`POST /modules/_work-context` takes the cluster-internal token plus the module's **identity** secret, body `{prefix}`. The gateway brokers it to accounts (`ModuleCapabilitiesService/MintModuleWorkContext`, `EXPOSURE_INTERNAL`), which diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index a6be39c24..135f47027 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -84,6 +84,8 @@ var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadat "/saas.accounts.v1.IdentityService/ResolveIdentity": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "cb57ee7f4e96ab9548ead04103dfd3e9a96dce973c06a8bd62a9ac991eab1e16"}, "/saas.accounts.v1.InstallationService/GetInstallation": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "ad07a0509490d86d9bb64cb28e5db5ef578575d2cbab8ee86d279ffe1392cca8"}, "/saas.accounts.v1.InstallationService/InstallSolution": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "f44d4ffd9edf43aa97546d85433b347bfcaa1d5d3d870b0a6dbc2a65431b638e"}, + "/saas.accounts.v1.InstallationService/ListAvailableSolutions": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "ef6a5e6e68c7115ca8783422cdc54febdd089c9b2660ac3774c7a4a4847e7270"}, + "/saas.accounts.v1.InstallationService/ListInstallations": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, "/saas.accounts.v1.InstallationService/TransferInstallationOwnership": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "8c63edb387aca3cf7afbd955dcbb58520aab0845ab47f031b512a3b784b037de"}, "/saas.accounts.v1.InstallationService/UninstallSolution": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "6ba619197f162c34b6166baecc383ebad32232ed83d3f19b2d89f3e4bd2f046f"}, "/saas.accounts.v1.IntrospectionService/GetServiceInfo": {exposure: edgeExposurePublic, rateLimitClass: edgeRateLimitClassPublic, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "4ab8ff160a72614e5ff4f0e73b3392532b05cfd70c53c44531377aa348cddc5d"}, @@ -212,9 +214,9 @@ var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadat "/saas.accounts.v1.SSOAdminService/Disable": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "2b87a08d8c272d59cf49a1b1e528caf4561681b3f18808b6ddebe409ae559643"}, "/saas.accounts.v1.SSOAdminService/GetSSO": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "ef6a5e6e68c7115ca8783422cdc54febdd089c9b2660ac3774c7a4a4847e7270"}, "/saas.accounts.v1.SSOAdminService/StartSetup": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "406b4292c916a9b7c1cf1394b27b3a047ab7958711bcb37a03dc7e7b822e3f00"}, - "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "f7ede710e2635a205b5ffc3e8079f7702560255d5b4c875d7b6af8e041998023"}, + "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, + "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, - "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "1186e85b490827dcfb7569fc99d65525ad21c30b1fa9652f67bbe7af76670371"}, "/saas.accounts.v1.TeamService/AddMember": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "f9fefcfc1b20a3f51792a5f81d53f1686ab64e49bf1b3f88f5f96147d148577c"}, "/saas.accounts.v1.TeamService/CreateTeam": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "657fedca1d4f7f664056d087302e72e2eea04262efbda5cbe6375dfa1d29bec1"}, "/saas.accounts.v1.TeamService/DeleteTeam": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "c1728f29b6bd8aecbfad8812608d7619f8dda4f0c011fa39aa38b554b18359b2"}, @@ -305,6 +307,7 @@ var generatedRESTProcedureByRoute = map[string]string{ "POST /v1/gdpr/export": "/saas.accounts.v1.GDPRService/RequestExport", "GET /v1/installations/{installation_id}": "/saas.accounts.v1.InstallationService/GetInstallation", "POST /v1/installations": "/saas.accounts.v1.InstallationService/InstallSolution", + "GET /v1/installations:listAvailable": "/saas.accounts.v1.InstallationService/ListAvailableSolutions", "POST /v1/installations/{installation_id}:transferOwnership": "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "POST /v1/installations/{installation_id}:uninstall": "/saas.accounts.v1.InstallationService/UninstallSolution", "GET /v1/.well-known/service-info": "/saas.accounts.v1.IntrospectionService/GetServiceInfo", diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index 0a9b3af67..e5e79fa1b 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -50,7 +50,6 @@ type Gateway struct { // clients is this replica's view of the registered-client registry: which // browser origins each first-party client speaks from (track 0016). clients *clientRegistryCache - modules *upstreamRegistry // runtime-registered composed-module REST upstreams // registeredTransport re-validates a runtime-registered upstream's resolved // address at dial time (SSRF / DNS-rebinding defense). Both federated module // and solution routes use it: a solution upstream is durable now (#534) but @@ -58,9 +57,12 @@ type Gateway struct { // bearers. Catalog upstreams are static trusted config and keep the default // transport. registeredTransport http.RoundTripper - // registrationReplay makes each solution-registration credential single-use. - registrationReplay *registrationReplayGuard - workContext *workContextVerifier + // solutionEntitlements answers what one viewer may use (#949). Set after + // construction, like workContext: a nil client fails the entitlement surface + // closed rather than answering an empty projection, which would retract every + // solution a viewer is currently using. + solutionEntitlements solutionEntitlementClient + workContext *workContextVerifier } // NewGateway constructs a gateway with explicit route matching. @@ -87,9 +89,7 @@ func NewGateway( requiredUpstreams: matcher.RequiredServices(), solutions: newSolutionRegistryCache(solutionRegistry), clients: newClientRegistryCache(clientRegistry), - modules: newUpstreamRegistry(), registeredTransport: newModuleUpstreamTransport(net.DefaultResolver), - registrationReplay: newRegistrationReplayGuard(), } mux := http.NewServeMux() mux.HandleFunc("/health", g.healthHandler) @@ -172,11 +172,27 @@ func (g *Gateway) readyHandler(w http.ResponseWriter, _ *http.Request) { // Unlisted paths are rejected with 404. Every request must match an explicit // entry in the route config. func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // A presented Work Context is verified before any routing so a forged, - // expired, or unattenuated capability is rejected at the edge rather than - // forwarded to a callee. Absent header: nothing to verify, carry on. - if g.rejectInvalidWorkContext(w, r) { - return + // A presented Work Context is verified at the edge, but NOT here: it is + // verified against the audience of the route it was presented to, so the route + // has to be resolved first. Each routing destination below calls + // rejectInvalidWorkContext with the audience its own resolution produced, and a + // path that resolves to no route reaches the 404 with the verifier never + // called. + // + // This used to run before any routing with no audience expectation at all, + // which meant a capability minted for one solution verified cleanly on another + // solution's route and was forwarded there: the callee was the only thing that + // could notice, and it was the callee's own audience check that had to. + + // The module credential exchanges and the CORS preflight run before routing by + // design (the internal-token header has not been stripped yet), so a capability + // presented to one of them is checked with NO audience expectation: their + // destination is accounts, one of this host's own services. The preflight + // carries no credential at all. + if strings.HasPrefix(r.URL.Path, modulePrefix) { + if g.rejectInvalidWorkContext(w, r, "") { + return + } } // A registered client's browser asks permission before it may call at all. @@ -196,22 +212,6 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Composed-module REST self-registration (/modules/_register): a privileged - // mutation authenticated on the X-Codefly-Internal-Token header, so it must - // run before withTrustedFrontendOrigin consumes and strips that header. It - // only stores a prefix→upstream mapping; every proxied module request below - // still runs the full auth pipeline. - if g.handleModuleRegister(w, r) { - return - } - - // The credential exchange that precedes it (/modules/_registration-token): - // same listener, same reason to run before the header is stripped, and it - // brokers to accounts rather than deciding anything itself. - if g.handleModuleRegistrationToken(w, r) { - return - } - // The module's other startup exchange (/modules/_work-context): the identity // it calls the module-facing capability surface with, brokered the same way // and gated on the same header this pass has yet to strip. @@ -237,17 +237,27 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { entry := g.matcher.Match(r.Method, r.URL.Path) if entry == nil { - // The generated + explicit catalog is the authority and always wins: - // module federation is attempted ONLY once the catalog has no match, so - // a registered prefix can never shadow a catalog route. An unregistered - // /v1//* prefix falls through to the 404 below. - if g.handleFederatedModule(w, r) { + // The generated + explicit catalog is the authority and always wins: a + // declared module's /v1//* surface is tried ONLY once the catalog + // has no match, so a declared alias can never shadow a catalog route. An + // alias nothing declared falls through to the 404 below, which is what + // keeps the surface from being a probe for which modules run here. + if g.handleDeclaredModule(w, r) { return } + // NO ROUTE RESOLVED, so the verifier was never called: a forged capability + // on a path this host does not expose is answered as the unexposed path it + // is, without a JWKS fetch or a signature check. log.Printf("WARN: blocked request: method=%s path=%s reason=no_matching_route", r.Method, r.URL.Path) httpError(w, http.StatusNotFound, "endpoint not exposed") return } + // A catalog route's destination is one of this host's own services, which has + // no audience of its own — the hop's name is never one — so the token is held + // to its signature and window and nothing more. + if g.rejectInvalidWorkContext(w, r, "") { + return + } if labeler, ok := otelhttp.LabelerFromContext(r.Context()); ok { labeler.Add(semconv.HTTPRoute(entry.Path)) } @@ -258,15 +268,21 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Which registered solution is minting a Work Context, read BEFORE the - // strip below removes the credential it is proved from. A refused - // credential never becomes an ordinary mint (verifiedSolutionMint). - solution, solutionPublisher, solutionRefused := g.verifiedSolutionMint(r, entry) - if solutionRefused { - log.Printf("WARN: blocked request: method=%s path=%s reason=invalid_solution_registration_credential", r.Method, r.URL.Path) - httpError(w, http.StatusUnauthorized, "unauthorized") - return - } + // THERE IS NO SOLUTION MINT AT THIS EDGE ANY MORE. This read a solution's + // signed, solution-bound REGISTRATION credential and asserted, to accounts, + // which registered solution was minting. That credential was issued by + // runtime self-registration, which this branch deletes, so the gateway has + // nothing left to prove the claim from — and an unprovable assertion of + // which solution is minting is worse than none, because accounts seals a + // runtime boundary from it. + // + // The gateway therefore asserts no solution identity. Both headers are + // still STRIPPED below — they are in `untrustedAuthHeaders`, under the + // `solutionIdentityHeader` / `solutionPublisherHeader` constants, which carry + // the exact spellings accounts reads — so a caller cannot supply what the + // gateway no longer stamps; they are simply never restamped. Accounts does + // not merely distrust them either: it REFUSES a request that carries one, + // because nothing on this host can prove the claim. // Identity and trust credentials are never accepted from the public side // of the gateway. ExtAuthz.Check only sees the caller's real credential @@ -348,13 +364,6 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) // Stamped after the identity projection, so it joins the headers - // accounts reads beside the gateway credential rather than being - // replaced by it. Set, never Add: a second value of a forwarded - // identity field is refused by accounts as ambiguous. - if solution != "" { - r.Header.Set(solutionIdentityHeader, solution) - r.Header.Set(solutionPublisherHeader, solutionPublisher) - } g.rateLimitThenProxy(w, r, upstream, entry) case "mfa_pending": @@ -392,6 +401,22 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { // rateLimitThenProxy applies rate limiting (if configured) then proxies. func (g *Gateway) rateLimitThenProxy(w http.ResponseWriter, r *http.Request, upstream *url.URL, entry *RouteEntry) { + g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.proxyTo(w, r, upstream, entry) + })) +} + +// rateLimitThenServe meters a request against its route's budget and then runs +// next. +// +// It exists so that work done BEFORE forwarding can also sit behind the budget. +// The solution proxy needs that: its admission check is an authority call to +// accounts, which costs a scope-tree read per entitlement page, and a request +// that ends in 403 or 503 costs exactly the same as one that is forwarded. With +// the limiter only in front of proxyTo, an authenticated caller could drive that +// cost without limit by asking about solutions it is not entitled to — the +// refusal was free, so there was nothing to exhaust. +func (g *Gateway) rateLimitThenServe(w http.ResponseWriter, r *http.Request, entry *RouteEntry, next http.Handler) { // Every forwarded request passes through here, whichever route matched it, // so this is where a request is bound to the origins its client registered. // Ahead of the limiter, not inside proxyTo: a refusal must not spend the @@ -402,12 +427,10 @@ func (g *Gateway) rateLimitThenProxy(w http.ResponseWriter, r *http.Request, ups return } if g.rateLimiter != nil { - g.rateLimiter.Middleware(limiterFailureModeFor(entry), rateLimitClassFor(entry), entry.AuthenticationFactorAttempt, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - g.proxyTo(w, r, upstream, entry) - })).ServeHTTP(w, r) + g.rateLimiter.Middleware(limiterFailureModeFor(entry), rateLimitClassFor(entry), entry.AuthenticationFactorAttempt, next).ServeHTTP(w, r) return } - g.proxyTo(w, r, upstream, entry) + next.ServeHTTP(w, r) } // rateLimitClassFor reads the route's declared edge budget class. A nil entry @@ -610,11 +633,28 @@ func injectHeaders(r *http.Request, headers []*corev3.HeaderValueOption) { } } -// rejectInvalidWorkContext fails closed on a presented-but-invalid Work Context. +// rejectInvalidWorkContext fails closed on a presented-but-invalid Work Context, +// against the audience of the ROUTE it was presented to. +// // It returns true when it has answered the request (401), which the caller must // treat as terminal. A request without the header, or with a header the edge is // not configured to verify, is left untouched for the normal routing path. -func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Request) bool { +// +// THE ROUTE IS RESOLVED BEFORE THE TOKEN IS JUDGED, which is why `expected` is a +// parameter rather than something this function works out. Every call site passes +// the audience its own resolution produced: +// +// - a solution route passes `solution:` of the binding the route +// resolved to, from the SAME carried resolution that chose the upstream; +// - a declared module route passes the module capability audience; +// - a catalog route passes EMPTY — its destination is one of this host's own +// services, and the hop's own name is never an audience; +// - a path that resolves to NO route never reaches here at all, so the verifier +// is never called for a request that was going to be a 404. +// +// A-for-B therefore refuses with the audience error rather than with a 404: the +// route resolves, and the capability presented to it names another consumer. +func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Request, expected string) bool { if g.workContext == nil { return false } @@ -624,7 +664,7 @@ func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Reques } token, err := workcontext.ParseWorkContextToken(raw) if err == nil { - err = g.workContext.Verify(r.Context(), token) + err = g.workContext.Verify(r.Context(), token, expected) } if err != nil { // Log the cause (not the token) so a wall of 401s can be told apart: @@ -671,16 +711,41 @@ var untrustedAuthHeaders = []string{ "x-authentication-methods", "x-auth-time", "x-assurance-level", "x-mfa-verified-at", "x-codefly-gateway-token", "x-codefly-internal-token", "x-codefly-public-origin", "x-codefly-module-secret", "x-codefly-solution-secret", "x-codefly-solution-registration", - // This gateway's assertion of which registered solution is minting a Work - // Context, and of its publisher. They select the runtime boundary accounts - // seals, so a caller that could set them would mint under another solution's - // boundary; both are stripped here and restamped only from a verified - // solution credential. + // The gateway's former assertion of which registered solution is minting a + // Work Context, and of its publisher. They select the runtime boundary + // accounts seals, so a caller that could set them would mint under another + // solution's boundary. THE GATEWAY NO LONGER STAMPS EITHER — the + // registration credential they were proved from is deleted — but they stay + // on this list, because stripping them is what stops a caller supplying + // them, and that matters more now than when something restamped them. + // + // Written as the CONSTANTS, never as literals. A literal here was wrong once: + // `"x-codefly-solution-identity"` was spelled for a header accounts does not + // read, so `x-codefly-solution-id` passed the strip untouched and a caller + // through this gateway could assert any solution's identity to accounts, + // which trusts the header beside a valid gateway token. solutionIdentityHeader, solutionPublisherHeader, clientIDHeader, } +// solutionIdentityHeader and solutionPublisherHeader are the headers accounts +// reads to decide which registered solution a Work Context mint is for. This +// gateway stamps NEITHER — the credential it proved them from is deleted — and +// they exist here for exactly one purpose: to be stripped, under the spelling +// accounts actually reads. +// +// They MUST stay byte-identical to accounts' +// `connect_auth_interceptor.go` constants, lowercased. `http.Header.Del` +// canonicalises its argument, so a near-miss spelling deletes a header nobody +// sends and silently leaves the real one in place: the strip reports no error +// and the hole is invisible. `TestSolutionIdentityHeadersAreStripped` is what +// holds the spelling, by asserting on the value the upstream receives. +const ( + solutionIdentityHeader = "x-codefly-solution-id" + solutionPublisherHeader = "x-codefly-solution-publisher" +) + // httpError writes a plain-text error response. Bodies are short, // machine-readable errors — no leaking of implementation details. func httpError(w http.ResponseWriter, code int, msg string) { diff --git a/module/services/auth-gateway/code/gateway_cold_cutover_test.go b/module/services/auth-gateway/code/gateway_cold_cutover_test.go new file mode 100644 index 000000000..34ee0d524 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_cold_cutover_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" +) + +func TestGateway_DeletedRegistrationPathsReturn404(t *testing.T) { + gateway := NewGateway(&ExtAuthz{}, NewRouteMatcher(nil, nil), nil, nil, nil, nil) + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions} { + for _, path := range []string{"/modules/_register", "/modules/_registration-token", "/solutions/_register", "/solutions/_frontend", "/solutions/_registration-token", "/solutions/_register/extra", "/solutions/_frontend/extra", "/solutions/_registration-token/extra", "/v1/example", "/v1/example/records", "/v1/example:query"} { + t.Run(method+" "+path, func(t *testing.T) { + req := httptest.NewRequest(method, path, strings.NewReader(`{"prefix":"example","upstream":"http://example.svc"}`)) + req.Header.Set("X-Codefly-Internal-Token", "example-internal-token") + req.Header.Set("X-Codefly-Module-Registration", "example-retired-credential") + response := httptest.NewRecorder() + gateway.ServeHTTP(response, req) + require.Equal(t, http.StatusNotFound, response.Code, response.Body.String()) + }) + } + } +} + +func TestDeclaredSolutionUpstreamReadPreservesURLAdmission(t *testing.T) { + for _, upstream := range []string{ + "http://169.254.169.254", "http://metadata.google.internal", "https://api.example.com", + "file://example.svc", "http://user:password@example.svc", "http://example.svc/path", "http://example.svc?secret=true", + } { + t.Run(upstream, func(t *testing.T) { + registry := newFakeSolutionRegistry() + registry.records["example"] = &accountsv1.SolutionRegistration{ + SolutionId: "example", Frontend: &accountsv1.SolutionFrontendBinding{}, + Backend: &accountsv1.SolutionBackendBinding{Upstream: upstream}, + Declared: &accountsv1.SolutionDeclaredBinding{TargetId: "example-target", + Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION}, + } + cache := newSolutionRegistryCache(registry) + require.NoError(t, cache.refresh(context.Background())) + routing, resolution := cache.resolveRouting(context.Background(), "example") + require.Nil(t, routing) + require.Equal(t, solutionNotActive, resolution) + }) + } +} diff --git a/module/services/auth-gateway/code/gateway_connection_options_test.go b/module/services/auth-gateway/code/gateway_connection_options_test.go index a5d04eca9..b33ba3f1f 100644 --- a/module/services/auth-gateway/code/gateway_connection_options_test.go +++ b/module/services/auth-gateway/code/gateway_connection_options_test.go @@ -88,20 +88,17 @@ func TestGateway_ConnectionHeaderCannotRemoveStampedHeaders_Session(t *testing.T require.Equal(t, "https://app.example.com", apiFake.lastHeaders.Get("X-Codefly-Public-Origin")) } -// Solution and module upstreams receive the identity the gateway stamps under +// Solution upstreams receive the identity the gateway stamps under // the same guarantee: every proxy path goes through proxyTo. -func TestGateway_ConnectionHeaderCannotRemoveStampedHeaders_SolutionAndModule(t *testing.T) { +func TestGateway_ConnectionHeaderCannotRemoveStampedHeaders_Solution(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) solution := registerSolutionUpstream(t, gw, "audit") - module, moduleURL := newModuleUpstream(t) - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", moduleURL).Code) for name, tc := range map[string]struct { path string upstream *fakeUpstream }{ "solution": {path: "/solutions/audit/v1/audit/logs", upstream: solution}, - "module": {path: "/v1/documents/collection", upstream: module}, } { t.Run(name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tc.path, nil) diff --git a/module/services/auth-gateway/code/gateway_module_routes.go b/module/services/auth-gateway/code/gateway_module_routes.go new file mode 100644 index 000000000..d26056d92 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_module_routes.go @@ -0,0 +1,202 @@ +package main + +// Routing a DECLARED module (issue #952). +// +// A composed module is reached at /v1//*, and the alias comes from the +// same place a solution's does: a presence document delivery handed this host, +// reconciled into the declared registry. Nothing self-registers. main's +// federation was the opposite — a module POSTed its own upstream to +// /modules/_register and the gateway believed it — which this branch deletes, so +// at the head before this file a composed module was not reachable over REST at +// all. +// +// This copies the solution shape rather than inventing a second one: the same +// cache, the same single carried resolution, the same upstream policy, the same +// tombstone semantics, the same 120 s revocation bound. What it deliberately does +// NOT copy is per-viewer admission. A solution route resolves the viewer's +// installation of the record's target; a module is part of the composition, not +// something an organisation installs, so the ordinary authenticated pipeline is +// the whole gate — exactly as main's federated route was. +// +// That asymmetry is why the registry must SAY which kind a record is, and why +// neither surface guesses: routing a solution's upstream here would put it behind +// no installation check at all. + +import ( + "net/http" + "strings" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "google.golang.org/grpc/codes" +) + +// moduleCapabilitiesAudience mirrors business.ModuleCapabilitiesAudience in +// accounts — the audience a capability addressed to the module capability surface +// carries. The gateway cannot import it (separate Go modules), so the two are +// pinned to each other by a test rather than left to agree by eye. +const moduleCapabilitiesAudience = "module-capabilities" + +// moduleServicePrefix marks the RouteEntry.Service of a declared module route. +// It is a pseudo-service name, so isAccountsRoute stays false and no +// gateway/public-origin credential is stamped for a module upstream. +const moduleServicePrefix = "module:" + +// v1Prefix extracts the `` from a `/v1//...` (or `/v1/`) +// path. It reports false for any path not under /v1/ or with an empty prefix. +// +// A custom-verb route ends its first segment with `:` +// (`/v1/permissions:check`, `/v1/work-contexts:renew`), and the verb is not part +// of the prefix. Splitting only on `/` made `permissions:check` the "prefix" of +// that route, which left the real prefix `permissions` absent from +// ReservedV1Prefixes — and made the lookup below compare a verb-suffixed segment +// against declared aliases, so `/v1/:` could never reach a module +// that owns the prefix. +func v1Prefix(path string) (string, bool) { + const root = "/v1/" + if !strings.HasPrefix(path, root) { + return "", false + } + rest := path[len(root):] + prefix := rest + if idx := strings.IndexByte(rest, '/'); idx >= 0 { + prefix = rest[:idx] + } + if idx := strings.IndexByte(prefix, ':'); idx >= 0 { + prefix = prefix[:idx] + } + if prefix == "" { + return "", false + } + return prefix, true +} + +// handleDeclaredModule proxies a /v1//* request to the upstream a declared +// module binding named. It returns true when it has answered. +// +// It runs ONLY after the catalog matcher found no route, so a generated or +// explicit route always wins over a declared alias. A path under no /v1/ prefix, +// and a prefix no record holds, return false and fall through to the caller's +// 404: an alias nothing declared stays indistinguishable from any other unexposed +// path, which is what keeps this from being a probe for which modules a +// deployment runs. +func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) bool { + alias, ok := v1Prefix(r.URL.Path) + if !ok { + return false + } + // A gateway wired with no registry client serves no declared modules AT ALL, + // which is not the same fact as a registry that is down: there is no authority + // here that could have been asked. Fall through to the caller's 404, so a + // deployment without the declared surface answers an unexposed path exactly as + // it did before this surface existed. + if g.solutions == nil || g.solutions.client == nil { + return false + } + routing, resolution := g.solutions.resolveModuleRouting(r.Context(), alias) + switch resolution { + case solutionUnregistered: + // Nothing declares this alias, or a tombstone withdrew it. Fall through + // to the 404 the caller already answers for an unexposed path. + return false + case solutionWrongKind: + // The alias IS declared, and as something this surface does not serve: a + // solution, or a record whose kind this gateway cannot read. A verdict + // (403), not an outage and not a 404 — the record is here and it is not a + // module, and no later read changes that. + // + // Deliberately not a fall-through to 404: a solution reached here would + // otherwise look unexposed while being served, with its per-viewer + // admission, two paths away. + httpError(w, http.StatusForbidden, "module is not declared on this host") + return true + case solutionNotActive: + // Declared but not serving: a half is absent or the halves disagree on a + // contract version. Distinct from "not declared" because it tells an + // operator to look for a deployment rather than for a delivery. + httpError(w, http.StatusServiceUnavailable, "module registration not active") + return true + case solutionRegistryUnavailable: + // No snapshot has ever loaded, so this replica cannot tell a declared + // module from an unexposed path. Fail closed and say so, rather than + // answering 404 — which would read as "this host does not serve that". + httpError(w, http.StatusServiceUnavailable, "solution registry unavailable") + return true + } + + // An alias the CATALOG owns is refused rather than served in part. The catalog + // matcher ran first and took every path it matches, so a declared module + // sharing one of its prefixes would serve only the paths the catalog happens + // not to match — a surface split between two authorities that nobody + // declared. main refused such a claim when a module registered itself; with + // declaration there is no registration to refuse here, so the refusal that + // belongs upstream (in what a delivery may declare) is at least not silently + // a half-served prefix. + if _, reserved := g.matcher.ReservedV1Prefixes()[alias]; reserved { + httpError(w, http.StatusForbidden, "module alias is owned by this host's own catalog") + return true + } + + // THE ROUTE IS RESOLVED, SO THE AUDIENCE IS KNOWN. A module route's target is + // the module capability surface, so that is what a capability presented here + // must name — the same derivation as a solution route, with the audience the + // host's vocabulary gives this kind. + if g.rejectInvalidWorkContext(w, r, moduleCapabilitiesAudience) { + return true + } + + // Same identity discipline as every protected route: drop caller-supplied + // identity, run ext_authz, require a valid credential. A bearer-less call is + // denied here, so a declared module adds a proxy target and never widens the + // authenticated surface. + stripAllIdentityHeaders(r) + checkResp, err := g.authz.Check(r.Context(), buildCheckRequest(r)) + if err != nil { + httpError(w, http.StatusInternalServerError, "auth check failed") + return true + } + if denied := checkResp.GetDeniedResponse(); denied != nil { + code := int(denied.GetStatus().GetCode()) + if code == 0 { + code = http.StatusForbidden + } + httpError(w, code, denied.GetBody()) + return true + } + if int(checkResp.GetStatus().GetCode()) != int(codes.OK) { + httpError(w, http.StatusForbidden, "forbidden") + return true + } + injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) + + // Forward the full /v1//... path unchanged: the module owns and serves + // its own /v1/ surface. The caller's bearer is preserved so the module + // can call back through the gateway on the user's behalf, exactly as solution + // passthrough does. + // + // rateLimitThenProxy, not proxyTo: a module data endpoint must consume the + // same per-org/per-IP budget an equivalent catalog route does, or /v1//* + // would be an unmetered proxy an authenticated caller could flood past the org + // budget. + entry := &RouteEntry{Service: moduleServicePrefix + alias, Protected: true} + g.rateLimitThenProxy(w, r, routing.Upstream, entry) + return true +} + +// declaredModuleKind reports whether a record's declaration says "module". +// +// The kind is read from the declaration, never from the record's shape. A +// publisher prefix or an alias convention would be a guess at the one question +// that separates a surface with per-viewer admission from one without, and +// UNSPECIFIED — what a reader decodes from a writer that did not set the field — +// is not a kind: it answers false here and false in the solution check, so such a +// record is served by neither surface rather than by whichever needs less +// authority. +func declaredModuleKind(record *accountsv1.SolutionRegistration) bool { + return record.GetDeclared().GetKind() == accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE +} + +// declaredSolutionKind is the same read for the solution surface. +func declaredSolutionKind(record *accountsv1.SolutionRegistration) bool { + return record.GetDeclared().GetKind() == accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION +} diff --git a/module/services/auth-gateway/code/gateway_module_routes_test.go b/module/services/auth-gateway/code/gateway_module_routes_test.go new file mode 100644 index 000000000..7fe6a2292 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_module_routes_test.go @@ -0,0 +1,302 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" +) + +// Routing a declared module (issue #952). +// +// The three cases the ruling names, each on the real ServeHTTP path rather than +// on the resolver alone: an undeclared prefix is not served, a declared module is +// routed, and an applied tombstone stops the routing. +// +// Every record here is created the way delivery creates one — a declaration with +// a kind — because the kind is the whole mechanism: it is what lets one registry +// feed two surfaces whose admission differs. + +// declareModule wires a declared MODULE record into the registry behind gw and +// returns the upstream's capture struct. +func declareModule(t *testing.T, gw *Gateway, alias string) *fakeUpstream { + t.Helper() + fake := &fakeUpstream{body: "module-response"} + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + registry := solutionRegistryFake(t, gw) + registry.mu.Lock() + registry.revision++ + registry.records[alias] = &accountsv1.SolutionRegistration{ + SolutionId: alias, + Publisher: "solution:" + alias, + Revision: registry.revision, + Declared: &accountsv1.SolutionDeclaredBinding{ + BindingId: "acme.test." + alias, + Generation: 1, + Release: "acme/" + alias + "@1.0.0", + TargetId: fakeSolutionTarget(alias), + Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE, + }, + // NO frontend half, which is the real shape: a module has no browser + // remote and nothing loads a manifest for it. Giving the fake one would + // hide the rule that makes modules routable at all — requiring both + // halves leaves every module permanently PENDING. + Backend: &accountsv1.SolutionBackendBinding{ + Revision: registry.revision, Upstream: srv.URL, + ServiceAlias: alias, ContractVersion: "v1", + }, + } + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) + return fake +} + +// A /v1//* path no declaration holds is answered exactly as any other +// unexposed path: 404, with no hint that the prefix was looked up. +// +// That is deliberate and not merely convenient. A distinct answer here would make +// the surface a probe for which modules a deployment runs — an unauthenticated +// caller could enumerate the composition by reading status codes. +func TestGateway_UndeclaredModulePrefixIsNotServed(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + + for _, name := range []string{"no bearer", "valid bearer"} { + req := httptest.NewRequest(http.MethodGet, "/v1/nothing-declares-this/things", nil) + if name == "valid bearer" { + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + } + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code, + "%s: an undeclared /v1 prefix must be indistinguishable from any unexposed path", name) + require.Contains(t, w.Body.String(), "endpoint not exposed", name) + } +} + +// A declared module IS routed: the full /v1//... path reaches the declared +// upstream, with the ordinary authenticated pipeline in front of it — identity +// projected from the validated token, caller-supplied identity stripped, the +// caller's bearer preserved, and the gateway's own accounts credential withheld. +func TestGateway_DeclaredModuleIsRoutedThroughTheAuthenticatedPipeline(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := declareModule(t, gw, "example-module") + + // Without a bearer the module is never reached: a declared module adds a + // proxy target and never widens the authenticated surface. + noTok := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, noTok) + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, fake.lastHeaders, "upstream must not be reached without auth") + + token := signValidToken(t, priv) + req := httptest.NewRequest(http.MethodGet, "/v1/example-module/things?limit=5", nil) + req.Header.Set("authorization", "Bearer "+token) + req.Header.Set("x-user-id", "attacker") + req.Header.Set("x-org-role", "super_admin") + w = httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "module-response", w.Body.String()) + // The path is forwarded UNCHANGED: the module owns its own /v1/ + // surface, unlike a solution whose prefix is stripped. + require.Equal(t, "/v1/example-module/things", fake.lastPath) + require.NotEqual(t, "attacker", fake.lastHeaders.Get("x-user-id")) + require.Equal(t, "admin", fake.lastHeaders.Get("x-org-role")) + require.Equal(t, "Bearer "+token, fake.lastHeaders.Get("authorization")) + require.Empty(t, fake.lastHeaders.Get("x-codefly-gateway-token"), + "the gateway token is an accounts-only capability and must not reach a module upstream") +} + +// An applied tombstone STOPS the routing, within the cache bound. +// +// This is the revocation path, and it is the reason the declared row is re-read +// per route instead of being resolved once into a static table: carrier deletion +// is not revocation — a mount going quiet means only that delivery stopped +// talking — and the applied tombstone is. The same record, the same alias, the +// same upstream still listening; what changed is the declaration. +func TestGateway_TombstonedModuleStopsRouting(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := declareModule(t, gw, "example-module") + + token := signValidToken(t, priv) + serve := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + return w + } + require.Equal(t, http.StatusOK, serve().Code, "the module must route first, or the tombstone below proves nothing") + + solutionRegistryFake(t, gw).tombstone("example-module") + require.NoError(t, gw.solutions.refresh(context.Background())) + + fake.lastHeaders = nil + w := serve() + require.Equal(t, http.StatusNotFound, w.Code, + "a tombstoned module routes exactly like an alias nothing ever declared") + require.Nil(t, fake.lastHeaders, "the upstream must not be reached after the tombstone") +} + +// A record declared as a SOLUTION is refused on the module surface by name, and a +// record declared as a MODULE is refused on the solution surface. +// +// Both halves, because the asymmetry is the point: the module surface carries no +// per-viewer installation admission, so serving a solution there would drop its +// installation check entirely. A 403 verdict rather than a 404 — the record is +// here, and it is not what this surface serves. +func TestGateway_ASurfaceRefusesTheOtherKindByName(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + // A declared SOLUTION, reached on the module surface. + registerSolutionUpstream(t, gw, "example-go") + token := signValidToken(t, priv) + + req := httptest.NewRequest(http.MethodGet, "/v1/example-go/things", nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "module is not declared on this host") + + // A declared MODULE, reached on the solution surface. + declareModule(t, gw, "example-module") + req = httptest.NewRequest(http.MethodGet, "/solutions/example-module/things", nil) + req.Header.Set("authorization", "Bearer "+token) + w = httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "solution is not declared on this host") +} + +// A record whose declaration carries NO kind is served by NEITHER surface. +// +// UNSPECIFIED is what a reader decodes from a writer that did not set the field, +// and it is not a third kind. The permissive reading would be "module", because +// that surface has no installation admission — so this asserts the refusal in +// both directions rather than only the one the implementation happens to check +// first. +func TestGateway_ADeclarationWithNoKindIsServedByNeitherSurface(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := declareModule(t, gw, "example-module") + registry := solutionRegistryFake(t, gw) + registry.mu.Lock() + registry.revision++ + record := registry.records["example-module"] + record.Revision = registry.revision + record.Declared.Kind = accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_UNSPECIFIED + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) + + token := signValidToken(t, priv) + for path, want := range map[string]string{ + "/v1/example-module/things": "module is not declared on this host", + "/solutions/example-module/things": "solution is not declared on this host", + } { + fake.lastHeaders = nil + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code, path) + require.Contains(t, w.Body.String(), want, path) + require.Nil(t, fake.lastHeaders, "%s: the upstream must not be reached", path) + } +} + +// The CATALOG always wins, and an alias it owns is refused rather than +// half-served. +// +// Two separate properties. First, a catalog route is matched before the module +// surface is consulted at all, so a declared alias can never shadow one. Second — +// and this is what the refusal is for — the matcher takes only the paths it +// matches, so a module declared on a catalog-owned prefix would serve exactly the +// paths the catalog happens not to match: one prefix split between two +// authorities, which nobody declared. +func TestGateway_ACatalogOwnedPrefixIsRefusedRatherThanHalfServed(t *testing.T) { + gw, api, _, priv := newGatewayHarness(t) + fake := declareModule(t, gw, "users") + token := signValidToken(t, priv) + + // The catalog route wins outright: GET /v1/users is the accounts route. + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "api-response", w.Body.String()) + require.NotNil(t, api.lastHeaders, "the catalog route must be the one served") + require.Nil(t, fake.lastHeaders, "the declared module must not be reached for a catalog path") + + // And the paths the catalog does NOT match are refused rather than proxied. + req = httptest.NewRequest(http.MethodGet, "/v1/users/self/declared-module-surface", nil) + req.Header.Set("authorization", "Bearer "+token) + w = httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "module alias is owned by this host's own catalog") + require.Nil(t, fake.lastHeaders, "a catalog-owned prefix must never reach a declared module") +} + +// A module upstream takes the GUARDED transport, the one that re-validates the +// resolved IPs at dial time. +// +// The address comes out of a delivered document and user bearers are forwarded to +// it, which is the same exposure a solution upstream has. A module route added +// without this would have been the one declared upstream dialled on the plain +// transport, and nothing about the route's shape would have said so. +func TestModuleRoutesTakeTheGuardedUpstreamTransport(t *testing.T) { + require.True(t, isRuntimeRegisteredRoute(&RouteEntry{Service: moduleServicePrefix + "example-module"}), + "a declared module upstream must take the resolve-validating transport") + require.True(t, isRuntimeRegisteredRoute(&RouteEntry{Service: solutionServicePrefix + "example-go"})) + require.False(t, isRuntimeRegisteredRoute(&RouteEntry{Service: "accounts"}), + "a static catalog route must not") +} + +// A module is SERVING on its backend half alone, and a module with no backend is +// not. +// +// This is the rule that makes modules routable at all. A solution needs both +// halves — it serves a page and a backend — but a module has no browser remote, +// so requiring a frontend would leave every module PENDING for ever: serving +// traffic the gateway refuses, while the registry reports it as waiting for a +// deployment that is not coming. +// +// Both directions, because a check that only ever saw "backend present" would +// pass against a gateway that stopped checking the backend at all. +func TestAModuleIsServingOnItsBackendHalfAlone(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := declareModule(t, gw, "example-module") + token := signValidToken(t, priv) + serve := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + return w + } + require.Equal(t, http.StatusOK, serve().Code, + "a declared module with a backend and NO frontend half must route") + + registry := solutionRegistryFake(t, gw) + registry.mu.Lock() + registry.revision++ + record := registry.records["example-module"] + record.Revision = registry.revision + record.Backend = nil + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) + + fake.lastHeaders = nil + w := serve() + require.Equal(t, http.StatusServiceUnavailable, w.Code, + "a module with no backend half has nothing to proxy to, and that is not the same fact as being undeclared") + require.Contains(t, w.Body.String(), "module registration not active") + require.Nil(t, fake.lastHeaders) +} diff --git a/module/services/auth-gateway/code/gateway_modules.go b/module/services/auth-gateway/code/gateway_modules.go index 983c419d9..d43ed150f 100644 --- a/module/services/auth-gateway/code/gateway_modules.go +++ b/module/services/auth-gateway/code/gateway_modules.go @@ -1,48 +1,6 @@ package main -// Composed-module REST federation. -// -// A composition can add modules and solutions that expose their own REST -// endpoints under /v1//*. Rather than redeploying this gateway with a -// new generated catalog every time, a composition-local module self-registers -// its upstream at startup (POST /modules/_register) and the gateway then proxies -// /v1//* to it — but ONLY after the generated catalog has no match, so a -// registered prefix can never shadow or override a catalog route. -// -// Registration only adds a proxy target; it never relaxes authentication. Every -// proxied /v1//* request runs the same ext_authz Check and identity -// projection as a protected catalog route, so a bearer-less call is denied at -// the gateway (401) regardless of what is registered. -// -// Trust model: registration is gated on a per-module cryptographic identity, not -// the shared cluster-internal token. The caller presents a signed registration -// token (X-Codefly-Module-Registration) that binds a module identity (its `sub`) -// to exactly one prefix; the gateway verifies it with the same alg-locked Ed25519 -// discipline as an access token (audience-locked to module registration, so an -// access token can never be replayed as a registration credential and vice -// versa). A module holding a token for "documents" therefore cannot register -// "billing" — the shared secret gave no such per-caller binding. The enforceable -// guardrails below are: authenticated AND prefix-bound (signed token), well-formed -// single-segment prefix, first-claim-wins (a prefix already held by a different -// upstream cannot be taken over), never shadowing the catalog, and a -// composition-local (mesh) upstream only. -// -// That token comes from accounts, the authority whose key the gateway already -// trusts through JWKS, and a module obtains one by exchanging the registration -// secret its composition provisioned (POST /modules/_registration-token below). -// The gateway cannot mint — it holds only the public half. What that buys is -// narrower than "separation of authority": a gateway compromise routes traffic -// anywhere regardless of who signs. It buys that the prefix→module binding is -// declared once, in configuration accounts reads, so no code path on the request -// side — here or in a module — can widen who may claim a prefix. -// -// The token binds the prefix (a module's stable identity), not the upstream URL, -// which is chosen at runtime (loopback in dev, cluster DNS in prod) and would be -// brittle to pin. The upstream is instead constrained by the mesh-host guard at -// register time and — because a registrant is no longer trusted to name mesh -// hosts that later resolve off-mesh — by a resolve-time address check at proxy -// dial time (isAllowedResolvedModuleIP), which closes the DNS-rebinding window a -// name-only check leaves open. +// Module credential exchanges and the solution upstream transport. import ( "context" @@ -51,233 +9,30 @@ import ( "log" "net" "net/http" - "net/url" "strings" - "sync" "time" accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - "github.com/golang-jwt/jwt/v5" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) -// upstreamRegistry is a process-local map of a routing key → upstream URL, -// populated at runtime by self-registration rather than at build time. It -// backs composed-module REST federation: a composed module the saas has no -// build-time knowledge of POSTs its upstream on startup, and the gateway then -// proxies matching /v1//* requests to it. -// -// The store is process-local, so with more than one auth-gateway replica a -// registration lands on one replica only and requests load-balanced to the -// others 404 until the module re-registers there. Solution registration used -// to share this limitation and no longer does (see -// gateway_solution_registry.go); module federation has not been given the same -// durable treatment. -type upstreamRegistry struct { - mu sync.RWMutex - upstreams map[string]*url.URL -} - -func newUpstreamRegistry() *upstreamRegistry { - return &upstreamRegistry{upstreams: make(map[string]*url.URL)} -} - -func (s *upstreamRegistry) get(id string) (*url.URL, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - upstream, ok := s.upstreams[id] - return upstream, ok -} - -// claim registers key→upstream when the key is free, or when it already points -// at the same upstream (idempotent re-registration on restart). It returns -// (existing, false) without mutating when the key is already held by a DIFFERENT -// upstream: first-claim-wins, so a later caller sharing the cluster-internal -// token cannot silently take over a key another component already registered. -func (s *upstreamRegistry) claim(id string, upstream *url.URL) (*url.URL, bool) { - s.mu.Lock() - defer s.mu.Unlock() - if existing, ok := s.upstreams[id]; ok { - if existing.String() != upstream.String() { - return existing, false - } - return existing, true - } - s.upstreams[id] = upstream - return upstream, true -} - -const moduleRegisterPath = "/modules/_register" - -// handleModuleRegister serves POST /modules/_register. It returns true when it -// has handled the request (the caller must then return). Any other path is left -// for the normal routing path. -func (g *Gateway) handleModuleRegister(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path != moduleRegisterPath { - return false - } - if r.Method != http.MethodPost { - httpError(w, http.StatusMethodNotAllowed, "method not allowed") - return true - } - - // Guardrail 1 — per-module cryptographic identity. Registration decides where - // authenticated module traffic (bearer + injected identity) is forwarded, so - // the caller must prove — with a signed, per-module registration token rather - // than the shared cluster-internal secret — that it owns the prefix it claims. - // verifyModuleRegistration fails closed on a missing/unset key, bad signature, - // wrong audience, or expiry, so an unauthenticated edge caller can never point - // a prefix at an attacker-controlled upstream and harvest forwarded bearers. - // The prefix→identity binding itself is enforced below, once the payload prefix - // is known. - claims, ok := g.authz.verifyModuleRegistration(r.Context(), r.Header.Get(moduleRegistrationHeader)) - if !ok { - httpError(w, http.StatusUnauthorized, "unauthorized") - return true - } - - var payload struct { - Prefix string `json:"prefix"` - Upstream string `json:"upstream"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - httpError(w, http.StatusBadRequest, "invalid json") - return true - } - - // Guardrail 2 (part) — identity-bound. Under a shared token the strongest - // well-formedness we can require is a single valid identity segment: no - // slashes, no wildcards, no traversal. A caller therefore claims exactly one - // module prefix (e.g. "documents"), never a path or a foreign nested route. - if !validCatalogIdentity(payload.Prefix) { - httpError(w, http.StatusBadRequest, "invalid prefix") - return true - } - - // Guardrail 1 (binding) — the registration token authorizes exactly one - // prefix. Reject a caller trying to register any other prefix, even with an - // otherwise-valid token: this is the per-module binding the shared secret - // lacked. (Both sides are already validated identity segments, so the prefix - // is not secret and a plain compare is fine.) - if claims.Prefix != payload.Prefix { - httpError(w, http.StatusForbidden, "prefix not authorized for this identity") - return true - } - - // Guardrail 3 — catalog-protected. The generated + explicit catalog owns its - // own /v1/ surface. Registering a colliding prefix is rejected rather - // than stored-but-unreachable (the catalog always wins in ServeHTTP), so the - // failure is loud instead of a silently dead route. - if _, reserved := g.matcher.ReservedV1Prefixes()[payload.Prefix]; reserved { - httpError(w, http.StatusConflict, "prefix reserved by catalog") - return true - } - - // Guardrail 4 — upstream constrained. Must parse as an http(s) URL with a - // host, and that host must be composition-local (mesh). An external URL or a - // public IP is rejected: a registered upstream receives forwarded bearers, so - // it must never be able to point off-mesh. - upstream, err := url.Parse(payload.Upstream) - if err != nil || (upstream.Scheme != "http" && upstream.Scheme != "https") || upstream.Host == "" { - httpError(w, http.StatusBadRequest, "invalid upstream") - return true - } - if isDisallowedRegisteredUpstreamHost(upstream.Hostname()) { - httpError(w, http.StatusBadRequest, "forbidden upstream host") - return true - } - - // Guardrail 2 (part) — first-claim-wins. A prefix already registered to a - // different upstream cannot be taken over by a later caller sharing the - // token; re-registering the same upstream (restart) is idempotent. - normalized := &url.URL{Scheme: upstream.Scheme, Host: upstream.Host} - if _, ok := g.modules.claim(payload.Prefix, normalized); !ok { - httpError(w, http.StatusConflict, "prefix already registered") - return true - } - - w.Header().Set("content-type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - return true -} - -// handleFederatedModule proxies a /v1//* request to a runtime-registered -// module upstream. It returns true when it has answered the request. It returns -// false — leaving the caller to answer 404 — for any path that is not under a -// registered module prefix, so an unregistered prefix is indistinguishable from -// any other unexposed path. +// isRuntimeRegisteredRoute selects the guarded transport — the one that +// re-validates the resolved IPs at dial time — for every upstream a DECLARATION +// named, rather than the static catalog. // -// This runs only after the catalog matcher returned no match, which guarantees a -// generated or explicit route always wins over a registered prefix. -func (g *Gateway) handleFederatedModule(w http.ResponseWriter, r *http.Request) bool { - prefix, ok := v1Prefix(r.URL.Path) - if !ok { - return false - } - upstream, ok := g.modules.get(prefix) - if !ok { - return false - } - - // Same discipline as every protected catalog route: drop caller-supplied - // identity, run ext_authz, require a valid credential, and subject the - // forwarded request to the same rate-limit budget (below). A bearer-less - // call is denied here, so federation only adds a proxy target — it never - // widens the authenticated surface, and it does not open an unmetered one. - stripAllIdentityHeaders(r) - checkResp, err := g.authz.Check(r.Context(), buildCheckRequest(r)) - if err != nil { - httpError(w, http.StatusInternalServerError, "auth check failed") - return true - } - if denied := checkResp.GetDeniedResponse(); denied != nil { - code := int(denied.GetStatus().GetCode()) - if code == 0 { - code = http.StatusForbidden - } - httpError(w, code, denied.GetBody()) - return true - } - if int(checkResp.GetStatus().GetCode()) != int(codes.OK) { - httpError(w, http.StatusForbidden, "forbidden") - return true - } - injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) - - // Forward the full /v1//... path unchanged: the module owns and - // serves its own /v1/ surface. The caller's bearer is preserved so - // the module can call back through the gateway on the user's behalf, exactly - // as solution passthrough does. Service is a "module:" pseudo-name so - // isAccountsRoute stays false and no gateway/public-origin credential is - // stamped for a federated upstream. - // - // Route through rateLimitThenProxy, not proxyTo directly: a federated data - // endpoint must consume the same per-org/per-IP budget as an equivalent - // catalog route (e.g. /v1/users), otherwise /v1//* would be an - // unmetered proxy an authenticated caller could flood past the org budget. - entry := &RouteEntry{Service: moduleServicePrefix + prefix, Protected: true} - g.rateLimitThenProxy(w, r, upstream, entry) - return true -} - -// moduleServicePrefix marks the RouteEntry.Service of a federated module route. -const moduleServicePrefix = "module:" - -// isRuntimeRegisteredRoute reports whether entry describes an upstream a -// registrant named at runtime — a federated module prefix or a solution — as -// opposed to a static catalog route. Only these get the resolve-time-validating -// transport in proxyTo. +// Both kinds qualify, for one reason: the address is read out of a delivered +// document and user bearers are forwarded to it, so the host must still refuse a +// name that resolves somewhere it will not send credentials. A module route added +// without this line would have been the one declared upstream dialled on the +// plain transport, and nothing about the route's shape would have said so. func isRuntimeRegisteredRoute(entry *RouteEntry) bool { - if entry == nil { - return false - } - return strings.HasPrefix(entry.Service, moduleServicePrefix) || - strings.HasPrefix(entry.Service, solutionServicePrefix) + return entry != nil && + (strings.HasPrefix(entry.Service, solutionServicePrefix) || + strings.HasPrefix(entry.Service, moduleServicePrefix)) } // meshHostSuffixes are the DNS suffixes that denote a composition-local @@ -293,11 +48,10 @@ var meshHostSuffixes = []string{ ".local", ".internal", ".svc", ".cluster.local", } -// isDisallowedRegisteredUpstreamHost reports whether the upstream host a -// registrant named must be rejected. It governs BOTH runtime-registered kinds — -// federated module prefixes and solutions — because both forward user bearers to -// a host the registrant chose. It is STRICTER than isForbiddenUpstreamHost, -// which it composes: beyond those SSRF sinks, a registered upstream must be +// isDisallowedRegisteredUpstreamHost validates a declared upstream read from +// the registry before the gateway forwards user credentials to it. It extends +// isForbiddenUpstreamHost, +// which it composes: beyond those SSRF sinks, a declared upstream must be // composition-local, so a public IP or an external dotted FQDN is also rejected. // Allowed: loopback, RFC1918/ULA private IPs, bare single-label service names // (including "localhost"), and cluster-suffixed names. @@ -330,159 +84,28 @@ func isDisallowedRegisteredUpstreamHost(host string) bool { return true } -// --- Per-module cryptographic identity --- - -// moduleRegistrationHeader carries the signed per-module registration token. -const moduleRegistrationHeader = "X-Codefly-Module-Registration" - -// moduleRegistrationAudience scopes a registration token to this single purpose. -// Access tokens carry aud "saas-starter"; registration tokens carry this. Even -// though both may be minted under the same Ed25519 key, the audience check makes -// the two non-interchangeable — a stolen access token cannot register a prefix, -// and a registration token cannot authenticate a user. -const moduleRegistrationAudience = "module-registration" - -// moduleRegistrationClaims is the signed assertion a module presents to register -// its prefix. `sub` is the module identity; Prefix is the single catalog-identity -// segment that identity is authorized to claim. The token binds the two -// cryptographically, so a module holding a token for "documents" cannot register -// "billing". -type moduleRegistrationClaims struct { - jwt.RegisteredClaims - Prefix string `json:"prefix"` -} - -// verifyModuleRegistration parses and validates a module registration token with -// the same alg-locked Ed25519 discipline as an access token — same published key -// set selected by the token's kid, plus issuer, expiry, and this -// module-registration audience. It returns the verified claims. It fails closed: -// a nil ext_authz check, an unreachable or unrecognised key, an empty/bad token, a wrong -// or absent audience, or an expired token all yield ok=false. -func (s *ExtAuthz) verifyModuleRegistration(ctx context.Context, tokenString string) (*moduleRegistrationClaims, bool) { - if s == nil || s.keys == nil || tokenString == "" { - return nil, false - } - claims := &moduleRegistrationClaims{} - parser := jwt.NewParser( - jwt.WithValidMethods([]string{"EdDSA"}), - jwt.WithIssuer(s.issuer), - jwt.WithAudience(moduleRegistrationAudience), - jwt.WithExpirationRequired(), - jwt.WithLeeway(tokenClockSkewLeeway), - ) - token, err := parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { - if t.Method.Alg() != "EdDSA" { - return nil, fmt.Errorf("alg forbidden: %s", t.Method.Alg()) - } - keyID, _ := t.Header["kid"].(string) - return s.keys.keyFor(ctx, keyID) - }) - if err != nil || !token.Valid { - return nil, false - } - return claims, true -} - -// --- Registration-credential exchange --- +// modulePrefix is the path prefix of the module credential exchanges. They run +// before routing because the internal-token header they authenticate on has not +// been stripped yet, so a capability presented to one of them is verified against +// this prefix rather than against a resolved route. +const modulePrefix = "/modules/" -// moduleRegistrationTokenPath serves the exchange a module runs immediately -// before /modules/_register: it presents the registration secret its -// composition gave it and receives the signed, prefix-bound token that endpoint -// requires. -const moduleRegistrationTokenPath = "/modules/_registration-token" - -// moduleSecretHeader carries the module's own registration secret. The gateway -// consumes it here and forwards it only on the internal leg, to accounts, which -// holds the digest to compare it against. +// moduleSecretHeader carries the module identity secret to accounts. const moduleSecretHeader = "X-Codefly-Module-Secret" -// mintModuleRegistrationMethod is accounts' credential-exchange RPC. It is -// EXPOSURE_INTERNAL, so the generated mesh policy admits this gateway's service -// account to exactly this path and denies every other principal; calling it by -// full method name over the internal listener keeps that guarantee, which a -// plain HTTP route on accounts would not have (the policy allowlist is built -// from internal proto methods). -const mintModuleRegistrationMethod = "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleRegistration" - -// moduleRegistrationExchangeTimeout bounds the internal leg. A module blocks on -// this during startup, so a stalled accounts must surface as a failed -// registration rather than a hung boot. -const moduleRegistrationExchangeTimeout = 10 * time.Second - -// handleModuleRegistrationToken serves POST /modules/_registration-token. It -// returns true when it has handled the request. -// -// The gateway brokers rather than mints: it holds only the public half of the -// signing key, and a composed module cannot reach accounts' internal listener -// itself — the mesh policy admits only this gateway's service account. So this -// handler authenticates the perimeter and forwards; accounts decides whether the -// caller owns the prefix, and this handler never learns which prefixes exist. -func (g *Gateway) handleModuleRegistrationToken(w http.ResponseWriter, r *http.Request) bool { - if r.URL.Path != moduleRegistrationTokenPath { - return false - } - if r.Method != http.MethodPost { - httpError(w, http.StatusMethodNotAllowed, "method not allowed") - return true - } +// ErrorInfo domain shared with accounts source-delegation refusals. +const solutionRegistryErrorDomain = "accounts.saas.codefly.dev" - // Perimeter check. The module secret alone identifies the module, but this - // listener has no rate limit on unrouted paths, so the cluster-internal token - // is required too: guessing a module secret then costs an attacker the shared - // credential first, rather than being free from anywhere that can reach the - // gateway. - if g.authz == nil || !g.authz.acceptsInternalToken(r.Header.Get("X-Codefly-Internal-Token")) { - httpError(w, http.StatusUnauthorized, "unauthorized") - return true - } - secret := r.Header.Get(moduleSecretHeader) - if secret == "" { - httpError(w, http.StatusUnauthorized, "unauthorized") - return true - } +// moduleExchangeTimeout bounds the accounts leg of a credential exchange. +const moduleExchangeTimeout = 10 * time.Second - var payload struct { - Prefix string `json:"prefix"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&payload); err != nil { - httpError(w, http.StatusBadRequest, "invalid json") - return true - } - if !validCatalogIdentity(payload.Prefix) { - httpError(w, http.StatusBadRequest, "invalid prefix") - return true - } - - ctx, cancel := context.WithTimeout(r.Context(), moduleRegistrationExchangeTimeout) - defer cancel() - issued, err := g.authz.mintModuleRegistration(ctx, payload.Prefix, secret) - if err != nil { - writeRegistrationExchangeFailure(w, err) - return true - } - - body, err := json.Marshal(map[string]string{ - "token": issued.GetToken(), - "expiresAt": issued.GetExpiresAt().AsTime().UTC().Format(time.RFC3339), - }) - if err != nil { - httpError(w, http.StatusBadGateway, "registration token unavailable") - return true - } - w.Header().Set("content-type", "application/json") - w.Header().Set("cache-control", "no-store") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - return true -} - -// moduleWorkContextPath serves the second exchange a module runs at startup: it +// moduleWorkContextPath serves the identity exchange a module runs at startup: it // presents its identity secret and receives the Work Context its // service principal calls the module-facing capability surface with. const moduleWorkContextPath = "/modules/_work-context" // mintModuleWorkContextMethod is accounts' Work Context mint for a composed -// module. EXPOSURE_INTERNAL like the credential exchange above, so the generated +// module. Its EXPOSURE_INTERNAL policy means the generated // mesh policy admits this gateway's service account and denies every other. const mintModuleWorkContextMethod = "/saas.accounts.v1.ModuleCapabilitiesService/MintModuleWorkContext" @@ -542,7 +165,7 @@ func (g *Gateway) handleModuleWorkContext(w http.ResponseWriter, r *http.Request return true } - ctx, cancel := context.WithTimeout(r.Context(), moduleRegistrationExchangeTimeout) + ctx, cancel := context.WithTimeout(r.Context(), moduleExchangeTimeout) defer cancel() issued, err := g.authz.mintModuleWorkContext(ctx, payload.Prefix, secret) if err != nil { @@ -630,7 +253,7 @@ func (g *Gateway) handleModuleOperationContext(w http.ResponseWriter, r *http.Re return true } - ctx, cancel := context.WithTimeout(r.Context(), moduleRegistrationExchangeTimeout) + ctx, cancel := context.WithTimeout(r.Context(), moduleExchangeTimeout) defer cancel() issued, err := g.authz.mintModuleOperationContext(ctx, payload.Prefix, secret, payload.Binding) if err != nil { @@ -670,25 +293,6 @@ func (g *Gateway) handleModuleOperationContext(w http.ResponseWriter, r *http.Re return true } -// mintModuleRegistration runs the internal leg over the existing accounts -// connection, presenting the gateway's own cluster-internal credential. There is -// no vendored client stub for ModuleCapabilitiesService, so the method is -// invoked by name against generated message types shared with accounts. -func (s *ExtAuthz) mintModuleRegistration( - ctx context.Context, prefix, secret string, -) (*accountsv1.ModuleMintRegistrationResponse, error) { - if s.backendConn == nil { - return nil, fmt.Errorf("accounts connection not configured") - } - ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", s.internalToken) - response := &accountsv1.ModuleMintRegistrationResponse{} - request := &accountsv1.ModuleMintRegistrationRequest{Prefix: prefix, Secret: secret} - if err := s.backendConn.Invoke(ctx, mintModuleRegistrationMethod, request, response); err != nil { - return nil, err - } - return response, nil -} - func (s *ExtAuthz) mintModuleWorkContext( ctx context.Context, prefix, secret string, ) (*accountsv1.ModuleMintWorkContextResponse, error) { @@ -807,7 +411,7 @@ func (g *Gateway) handleModuleSourceOperationContext(w http.ResponseWriter, r *h return true } - ctx, cancel := context.WithTimeout(r.Context(), moduleRegistrationExchangeTimeout) + ctx, cancel := context.WithTimeout(r.Context(), moduleExchangeTimeout) defer cancel() issued, err := g.authz.mintSourceOperationContext(ctx, request) if err != nil { @@ -897,8 +501,8 @@ type moduleResolver interface { } // newModuleUpstreamTransport returns a reverse-proxy transport that re-validates -// a module upstream's RESOLVED address at dial time. A registrant names only a -// mesh host string, checked at register time (isDisallowedRegisteredUpstreamHost); +// a solution upstream's RESOLVED address at dial time. The registry read checks +// the mesh host string (isDisallowedRegisteredUpstreamHost); // but a name it controls can resolve off-mesh at proxy time (DNS rebinding). This // transport resolves the host, rejects the dial unless every resolved address is // mesh-local, then connects to a validated IP directly — never re-resolving — so @@ -961,8 +565,8 @@ func validateResolvedModuleAddrs(addrs []net.IPAddr) error { return nil } -// isAllowedResolvedModuleIP is the resolve-time counterpart to the register-time -// host-string guard (isDisallowedRegisteredUpstreamHost): it re-checks the ACTUAL +// isAllowedResolvedModuleIP is the resolve-time counterpart to the registry-read +// host-string guard (isDisallowedRegisteredUpstreamHost): it re-checks the actual // address a module hostname resolved to. Only loopback and private (RFC1918 / // ULA) ranges are composition-local; the unspecified, link-local (covering the // 169.254.169.254 cloud-metadata IP), and every globally routable address are diff --git a/module/services/auth-gateway/code/gateway_modules_test.go b/module/services/auth-gateway/code/gateway_modules_test.go index c96a35897..162dacbd8 100644 --- a/module/services/auth-gateway/code/gateway_modules_test.go +++ b/module/services/auth-gateway/code/gateway_modules_test.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/ed25519" "encoding/json" "fmt" "net" @@ -15,7 +14,6 @@ import ( accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -25,415 +23,6 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -// newModuleUpstream starts a fake upstream and returns it with its (loopback) -// URL, which is a valid composition-local host for registration. -func newModuleUpstream(t *testing.T) (*fakeUpstream, string) { - t.Helper() - fake := &fakeUpstream{body: "module-response"} - srv := httptest.NewServer(fake) - t.Cleanup(srv.Close) - return fake, srv.URL -} - -// signModuleRegistrationToken mints a per-module registration token binding the -// module identity to a single prefix, signed with the same Ed25519 key the -// harness configures as the ext_authz check's public key. -func signModuleRegistrationToken(t *testing.T, priv ed25519.PrivateKey, prefix string) string { - t.Helper() - c := moduleRegistrationClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "saas-starter", - Subject: "module:" + prefix, - Audience: jwt.ClaimStrings{moduleRegistrationAudience}, - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), - }, - Prefix: prefix, - } - signed, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c).SignedString(priv) - require.NoError(t, err) - return signed -} - -// registerModule POSTs /modules/_register with a per-module registration token -// bound to prefix, and returns the response recorder. -func registerModule(t *testing.T, gw *Gateway, priv ed25519.PrivateKey, prefix, upstream string) *httptest.ResponseRecorder { - t.Helper() - body := fmt.Sprintf(`{"prefix":%q,"upstream":%q}`, prefix, upstream) - req := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(body)) - req.Header.Set(moduleRegistrationHeader, signModuleRegistrationToken(t, priv, prefix)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - return w -} - -// ============================================================================ -// End-to-end: a composed module federates its REST surface through the gateway -// with no gateway redeploy — and is still fully authenticated. -// ============================================================================ - -// After a module registers, /v1//* is proxied to it with the caller's -// identity projected exactly as a protected catalog route, and the module owns -// its full /v1/ path. -func TestGateway_Module_Federated_ValidJWT_Proxied(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - moduleFake, upstream := newModuleUpstream(t) - - regResp := registerModule(t, gw, priv, "documents", upstream) - require.Equal(t, http.StatusOK, regResp.Code) - - req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "module-response", w.Body.String()) - // The module owns and serves its own /v1/ surface — the path is - // forwarded unchanged. - require.Equal(t, "/v1/documents/collection", moduleFake.lastPath) - // ext_authz-stamped canonical identity reaches the module. - require.NotEmpty(t, moduleFake.lastHeaders.Get("x-user-id")) - require.NotEmpty(t, moduleFake.lastHeaders.Get("x-org-id")) - // A federated upstream is NOT accounts, so the gateway credential is never - // stamped for it. - require.Empty(t, moduleFake.lastHeaders.Get("x-codefly-gateway-token")) - // Caller-supplied identity headers are stripped before projection. - require.Empty(t, moduleFake.lastHeaders.Get("x-codefly-internal-token")) -} - -// A federated module may transcode REST to gRPC the way accounts does, so the -// Grpc-Metadata- spelling of an identity header is dropped here too. -func TestGateway_Module_Federated_StripsGRPCMetadataPrefixedHeaders(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - moduleFake, upstream := newModuleUpstream(t) - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstream).Code) - - req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - setGRPCMetadataSpellings(req, "attacker") - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - requireNoGRPCMetadataHeaders(t, moduleFake.lastHeaders) -} - -// A federated route consumes the same per-org rate-limit budget as an -// equivalent catalog route: it must NOT be an unmetered proxy. With a tiny -// budget, a burst of authenticated requests to /v1//* eventually 429s, -// exactly as a catalog route would. (Regression guard: the pre-fix code proxied -// federated routes directly, bypassing the limiter, so this never 429'd.) -func TestGateway_Module_Federated_RateLimited(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. - gw.rateLimiter = NewRateLimiter(1) - moduleFake, upstream := newModuleUpstream(t) - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstream).Code) - - // Reuse ONE token so every request keys on the same injected x-org-id. - token := signValidToken(t, priv) - got429 := false - for i := 0; i < 5; i++ { - req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - req.Header.Set("authorization", "Bearer "+token) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - if w.Code == http.StatusTooManyRequests { - got429 = true - break - } - require.Equal(t, http.StatusOK, w.Code) - } - require.True(t, got429, "federated module route must be subject to the rate-limit budget") - // The limiter rejects at the gateway; a throttled request never reaches the - // module upstream on that pass, but the earlier allowed ones did. - require.NotNil(t, moduleFake.lastHeaders) -} - -// A custom-verb path under a registered module prefix reaches that module. -// v1Prefix split the first segment on "/" alone, so /v1/documents:export -// resolved to the prefix "documents:export", missed the registry, and answered -// 404 — a registered module could serve /v1/documents/... but never any verb -// path, and the 404 was indistinguishable from an unregistered prefix. -func TestGateway_Module_Federated_CustomVerbPath(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - moduleFake, upstream := newModuleUpstream(t) - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstream).Code) - - req := httptest.NewRequest(http.MethodPost, "/v1/documents:export", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "/v1/documents:export", moduleFake.lastPath) -} - -// Security invariant: registration adds a proxy target, never an auth bypass. A -// bearer-less call to a registered module prefix is denied at the gateway (401) -// and the module upstream is never reached. -func TestGateway_Module_Federated_NoToken_Denied(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - moduleFake, upstream := newModuleUpstream(t) - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstream).Code) - - req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusUnauthorized, w.Code) - require.Nil(t, moduleFake.lastHeaders, "module upstream must NEVER be reached without auth") -} - -// An unregistered /v1//* prefix is indistinguishable from any other -// unexposed path: 404, no federation. -func TestGateway_Module_Unregistered_NotFound(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - - req := httptest.NewRequest(http.MethodGet, "/v1/ghost/thing", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusNotFound, w.Code) - require.Contains(t, w.Body.String(), "not exposed") -} - -// ============================================================================ -// Guardrail 1 — per-module cryptographic identity: registration requires a -// valid signed registration token, and that token binds the caller to exactly -// one prefix. -// ============================================================================ - -// Registration is gated on the signed per-module token, NOT the shared -// cluster-internal secret: a missing token, a malformed token, and a token -// signed for the wrong purpose (a user access token) are all rejected, and -// nothing is registered. -func TestGateway_ModuleRegister_RequiresRegistrationToken(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - moduleFake, upstream := newModuleUpstream(t) - body := fmt.Sprintf(`{"prefix":"documents","upstream":%q}`, upstream) - - post := func(setHeader func(*http.Request)) int { - req := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(body)) - if setHeader != nil { - setHeader(req) - } - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - return w.Code - } - - // No token. - require.Equal(t, http.StatusUnauthorized, post(nil)) - // Garbage token. - require.Equal(t, http.StatusUnauthorized, post(func(r *http.Request) { - r.Header.Set(moduleRegistrationHeader, "not-a-jwt") - })) - // The old shared cluster-internal token is no longer accepted here. - require.Equal(t, http.StatusUnauthorized, post(func(r *http.Request) { - r.Header.Set("X-Codefly-Internal-Token", "test-internal-token") - })) - // A valid USER access token (wrong audience) cannot double as a registration - // credential — audience-locking keeps the two token types non-interchangeable. - require.Equal(t, http.StatusUnauthorized, post(func(r *http.Request) { - r.Header.Set(moduleRegistrationHeader, signValidToken(t, priv)) - })) - - // Nothing was registered: the prefix stays unrouted (404 even with a valid - // bearer), and the (would-be) upstream is never reached. - _, ok := gw.modules.get("documents") - require.False(t, ok, "a rejected registration must not store the prefix") - - req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusNotFound, w.Code) - require.Nil(t, moduleFake.lastHeaders, "unregistered upstream must never be reached") -} - -// A token signed by the wrong key is rejected: verification is a real signature -// check, not merely a well-formedness check. -func TestGateway_ModuleRegister_RejectsForeignKey(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - _, upstream := newModuleUpstream(t) - - _, foreignPriv, err := ed25519.GenerateKey(nil) - require.NoError(t, err) - w := registerModule(t, gw, foreignPriv, "documents", upstream) - require.Equal(t, http.StatusUnauthorized, w.Code) - _, ok := gw.modules.get("documents") - require.False(t, ok) -} - -// An expired registration token is rejected (fail closed on expiry). -func TestGateway_ModuleRegister_RejectsExpiredToken(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - _, upstream := newModuleUpstream(t) - - c := moduleRegistrationClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "saas-starter", - Subject: "module:documents", - Audience: jwt.ClaimStrings{moduleRegistrationAudience}, - IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), - }, - Prefix: "documents", - } - expired, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c).SignedString(priv) - require.NoError(t, err) - - body := `{"prefix":"documents","upstream":"` + upstream + `"}` - req := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(body)) - req.Header.Set(moduleRegistrationHeader, expired) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusUnauthorized, w.Code) -} - -// The token binds ONE prefix to the module identity: a caller holding a valid -// token for "documents" cannot register any other prefix, even a well-formed, -// non-catalog one. This is the per-caller binding the shared secret lacked. -func TestGateway_ModuleRegister_EnforcesPrefixBinding(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - _, upstream := newModuleUpstream(t) - - // Token authorizes "documents"; payload tries to claim "reports". - body := fmt.Sprintf(`{"prefix":"reports","upstream":%q}`, upstream) - req := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(body)) - req.Header.Set(moduleRegistrationHeader, signModuleRegistrationToken(t, priv, "documents")) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusForbidden, w.Code) - _, ok := gw.modules.get("reports") - require.False(t, ok, "a prefix the token does not authorize must not be registered") - - // The identity's own prefix still registers fine. - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstream).Code) -} - -// ============================================================================ -// Guardrail 2 — well-formed single-segment prefix + first-claim-wins. -// ============================================================================ - -func TestGateway_ModuleRegister_RejectsMalformedPrefix(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - _, upstream := newModuleUpstream(t) - - for _, prefix := range []string{ - "", // empty - "docs/collection", // a path, not a single segment - "../secret", // traversal - "Documents", // uppercase (not a valid identity) - "docs_underscore", // underscore not in identity charset - strings.Repeat("a", 64), // longer than 63 - } { - // A valid token (bound to some prefix) gets past Guardrail 1; the malformed - // PAYLOAD prefix is rejected by the well-formedness check that runs before - // the binding compare. - body := fmt.Sprintf(`{"prefix":%q,"upstream":%q}`, prefix, upstream) - req := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(body)) - req.Header.Set(moduleRegistrationHeader, signModuleRegistrationToken(t, priv, "placeholder")) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equalf(t, http.StatusBadRequest, w.Code, "prefix %q must be rejected", prefix) - } -} - -// A prefix already held by a different upstream cannot be taken over; the same -// upstream re-registering is idempotent. -func TestGateway_ModuleRegister_FirstClaimWins(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - _, upstreamA := newModuleUpstream(t) - _, upstreamB := newModuleUpstream(t) - - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstreamA).Code) - - // A different upstream trying to claim the same prefix is rejected. - conflict := registerModule(t, gw, priv, "documents", upstreamB) - require.Equal(t, http.StatusConflict, conflict.Code) - - // The original registration still stands. - stored, ok := gw.modules.get("documents") - require.True(t, ok) - require.Equal(t, mustHost(t, upstreamA), stored.Host) - - // Idempotent re-registration of the same upstream succeeds. - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", upstreamA).Code) -} - -// ============================================================================ -// Guardrail 3 — catalog-protected: a registration can never shadow the catalog. -// ============================================================================ - -// Registering a prefix the catalog already owns is rejected loudly. (The token -// is minted per-prefix directly in the test, bypassing the real minting -// authority, so the catalog guard is exercised in isolation.) -func TestGateway_ModuleRegister_RejectsCatalogPrefix(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - _, upstream := newModuleUpstream(t) - - // The test catalog owns /v1/users, /v1/auth, /v1/mfa, /v1/billing, ... - for _, prefix := range []string{"users", "auth", "billing", "mfa"} { - w := registerModule(t, gw, priv, prefix, upstream) - require.Equalf(t, http.StatusConflict, w.Code, "catalog prefix %q must be reserved", prefix) - } -} - -// Even if a module is somehow registered under a catalog-owned prefix (bypassing -// the registration guard), the catalog still wins: the request routes to the -// catalog upstream, never the module. This is the structural invariant — -// federation is attempted ONLY after the matcher misses. -func TestGateway_Module_CatalogAlwaysWins(t *testing.T) { - gw, apiFake, _, priv := newGatewayHarness(t) - moduleFake, upstream := newModuleUpstream(t) - - // Force a shadowing registration directly into the registry. - u, err := url.Parse(upstream) - require.NoError(t, err) - _, claimed := gw.modules.claim("users", u) - require.True(t, claimed) - - req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "api-response", w.Body.String(), "catalog upstream must serve /v1/users") - require.NotNil(t, apiFake.lastHeaders, "catalog (accounts) upstream must be reached") - require.Nil(t, moduleFake.lastHeaders, "shadowing module upstream must NEVER be reached") -} - -// ============================================================================ -// Guardrail 4 — upstream constrained: composition-local (mesh) only. -// ============================================================================ - -func TestGateway_ModuleRegister_RejectsNonMeshUpstream(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - - for _, upstream := range []string{ - "http://8.8.8.8", // public IP - "http://93.184.216.34:8080", // public IP with port - "https://api.example.com", // external FQDN - "http://169.254.169.254/", // cloud metadata (link-local) - "http://metadata.google.internal", // metadata hostname - "ftp://internal", // wrong scheme - "http://", // no host - } { - w := registerModule(t, gw, priv, "documents", upstream) - require.GreaterOrEqualf(t, w.Code, 400, "upstream %q must be rejected", upstream) - require.Lessf(t, w.Code, 500, "upstream %q rejection is a client error", upstream) - } -} - -// isDisallowedRegisteredUpstreamHost draws the mesh-local line: loopback, private -// ranges, bare service names, and cluster suffixes are allowed; public IPs, -// external FQDNs, and SSRF sinks are not. func TestIsDisallowedModuleUpstreamHost(t *testing.T) { allowed := []string{ "127.0.0.1", "::1", "localhost", @@ -538,67 +127,6 @@ func mustPort(t *testing.T, rawURL string) string { return u.Port() } -// A mesh hostname that passes the register-time string guard but, at PROXY time, -// resolves to a public IP (DNS rebinding) must have its dial refused: the caller -// gets a 502 and the address is never connected to. The same name resolving to -// loopback still proxies, so a legitimate mesh upstream is unaffected. -func TestGateway_Module_ResolveTimeRebindingBlocked(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - moduleFake, realURL := newModuleUpstream(t) // http://127.0.0.1:PORT - port := mustPort(t, realURL) - - // Register a mesh-looking host (documents.svc passes isDisallowedRegisteredUpstreamHost). - meshUpstream := "http://documents.svc:" + port - require.Equal(t, http.StatusOK, registerModule(t, gw, priv, "documents", meshUpstream).Code) - - proxy := func() *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/v1/documents/x", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - return w - } - - // Rebinding: the mesh name resolves to a public IP at dial time -> refused. - gw.registeredTransport = newModuleUpstreamTransport(stubResolver{ - "documents.svc": {net.ParseIP("8.8.8.8")}, - }) - w := proxy() - require.Equal(t, http.StatusBadGateway, w.Code) - require.Contains(t, w.Body.String(), "forbidden upstream address") - require.Nil(t, moduleFake.lastHeaders, "a rebound public address must never be dialed") - - // The same name resolving to loopback still proxies (mesh dev / tests). - gw.registeredTransport = newModuleUpstreamTransport(stubResolver{ - "documents.svc": {net.ParseIP("127.0.0.1")}, - }) - w = proxy() - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "module-response", w.Body.String()) - require.NotNil(t, moduleFake.lastHeaders, "a loopback-resolved mesh upstream must be reached") -} - -// ============================================================================ -// Register endpoint hygiene. -// ============================================================================ - -func TestGateway_ModuleRegister_MethodAndBody(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - - // GET is not allowed (method is checked before auth). - getReq := httptest.NewRequest(http.MethodGet, moduleRegisterPath, nil) - w := httptest.NewRecorder() - gw.ServeHTTP(w, getReq) - require.Equal(t, http.StatusMethodNotAllowed, w.Code) - - // Invalid JSON, with a valid token so it reaches the decode step. - badJSON := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(`{not json`)) - badJSON.Header.Set(moduleRegistrationHeader, signModuleRegistrationToken(t, priv, "documents")) - w = httptest.NewRecorder() - gw.ServeHTTP(w, badJSON) - require.Equal(t, http.StatusBadRequest, w.Code) -} - func mustHost(t *testing.T, rawURL string) string { t.Helper() u, err := url.Parse(rawURL) @@ -606,257 +134,19 @@ func mustHost(t *testing.T, rawURL string) string { return u.Host } -// ============================================================================ -// Registration-credential exchange (/modules/_registration-token) -// ============================================================================ - -// fakeAccountsMint stands in for accounts' MintModuleRegistration RPC. It is -// registered under the real service name and method, so the test exercises the -// exact procedure string the generated mesh policy admits, and it signs with the -// key the harness publishes as the ext_authz check's public key, as accounts does. -type fakeAccountsMint struct { - priv ed25519.PrivateKey - code codes.Code - lastInternal string - lastPrefix string - lastSecret string - requestCount int -} - -func (f *fakeAccountsMint) handle(ctx context.Context, dec func(any) error) (any, error) { - req := &accountsv1.ModuleMintRegistrationRequest{} - if err := dec(req); err != nil { - return nil, err - } - f.requestCount++ - f.lastPrefix = req.GetPrefix() - f.lastSecret = req.GetSecret() - if md, ok := metadata.FromIncomingContext(ctx); ok { - if values := md.Get("x-codefly-internal-token"); len(values) > 0 { - f.lastInternal = values[0] - } - } - if f.code != codes.OK { - return nil, status.Error(f.code, "denied") - } - claims := moduleRegistrationClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "saas-starter", - Subject: "module:" + req.GetPrefix(), - Audience: jwt.ClaimStrings{moduleRegistrationAudience}, - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), - }, - Prefix: req.GetPrefix(), - } - signed, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(f.priv) - if err != nil { - return nil, err - } - return &accountsv1.ModuleMintRegistrationResponse{ - Token: signed, - ExpiresAt: timestamppb.New(time.Now().Add(5 * time.Minute)), - }, nil -} - -// newExchangeHarness starts a gRPC server serving the mint procedure and points -// the ext_authz check's accounts connection at it. -func newExchangeHarness(t *testing.T) (*Gateway, *fakeAccountsMint, ed25519.PrivateKey) { - t.Helper() - gw, _, _, priv := newGatewayHarness(t) - mint := &fakeAccountsMint{priv: priv} - - server := grpc.NewServer() - server.RegisterService(&grpc.ServiceDesc{ - ServiceName: "saas.accounts.v1.ModuleCapabilitiesService", - HandlerType: (*any)(nil), - Methods: []grpc.MethodDesc{{ - MethodName: "MintModuleRegistration", - Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { - return mint.handle(ctx, dec) - }, - }}, - Metadata: "saas/accounts/v1/module_registration.proto", - }, mint) - - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - go func() { _ = server.Serve(listener) }() - t.Cleanup(server.Stop) - - conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - t.Cleanup(func() { _ = conn.Close() }) - gw.authz.backendConn = conn - return gw, mint, priv -} - -func exchangeTokenRequest(prefix, secret, internal string) *http.Request { - req := httptest.NewRequest(http.MethodPost, moduleRegistrationTokenPath, - strings.NewReader(fmt.Sprintf(`{"prefix":%q}`, prefix))) - if secret != "" { - req.Header.Set(moduleSecretHeader, secret) - } - if internal != "" { - req.Header.Set("X-Codefly-Internal-Token", internal) - } - return req -} - -func TestGateway_ModuleRegistrationToken_BrokersToAccounts(t *testing.T) { - gw, mint, _ := newExchangeHarness(t) - - w := httptest.NewRecorder() - gw.ServeHTTP(w, exchangeTokenRequest("documents", "documents-secret", "test-internal-token")) - - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "no-store", w.Header().Get("cache-control")) - // The gateway presents its OWN cluster credential on the internal leg and - // forwards the module's secret for accounts to judge. - require.Equal(t, "test-internal-token", mint.lastInternal) - require.Equal(t, "documents", mint.lastPrefix) - require.Equal(t, "documents-secret", mint.lastSecret) - - var payload struct { - Token string `json:"token"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) - require.NotEmpty(t, payload.Token) -} - -// The exchange feeds the endpoint it exists for: a module that runs the full -// handshake ends up with a routed, still-authenticated federated prefix. -func TestGateway_ModuleRegistrationToken_CompletesHandshake(t *testing.T) { - gw, _, priv := newExchangeHarness(t) - moduleFake, upstream := newModuleUpstream(t) - - w := httptest.NewRecorder() - gw.ServeHTTP(w, exchangeTokenRequest("documents", "documents-secret", "test-internal-token")) - require.Equal(t, http.StatusOK, w.Code) - var issued struct { - Token string `json:"token"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &issued)) - - regBody := fmt.Sprintf(`{"prefix":"documents","upstream":%q}`, upstream) - regReq := httptest.NewRequest(http.MethodPost, moduleRegisterPath, strings.NewReader(regBody)) - regReq.Header.Set(moduleRegistrationHeader, issued.Token) - regResp := httptest.NewRecorder() - gw.ServeHTTP(regResp, regReq) - require.Equal(t, http.StatusOK, regResp.Code) - - proxied := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - proxied.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - proxiedResp := httptest.NewRecorder() - gw.ServeHTTP(proxiedResp, proxied) - require.Equal(t, http.StatusOK, proxiedResp.Code) - require.Equal(t, "/v1/documents/collection", moduleFake.lastPath) - - // Registration added a target, not an auth bypass. - unauth := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) - unauthResp := httptest.NewRecorder() - gw.ServeHTTP(unauthResp, unauth) - require.Equal(t, http.StatusUnauthorized, unauthResp.Code) -} - -func TestGateway_ModuleRegistrationToken_FailsClosed(t *testing.T) { - tests := map[string]struct { - prefix string - secret string - internal string - want int - }{ - "no internal token": {"documents", "documents-secret", "", http.StatusUnauthorized}, - "bad internal token": {"documents", "documents-secret", "wrong", http.StatusUnauthorized}, - "no module secret": {"documents", "", "test-internal-token", http.StatusUnauthorized}, - "path prefix": {"documents/nested", "documents-secret", "test-internal-token", http.StatusBadRequest}, - "wildcard prefix": {"*", "documents-secret", "test-internal-token", http.StatusBadRequest}, - "empty prefix": {"", "documents-secret", "test-internal-token", http.StatusBadRequest}, - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - gw, mint, _ := newExchangeHarness(t) - - w := httptest.NewRecorder() - gw.ServeHTTP(w, exchangeTokenRequest(test.prefix, test.secret, test.internal)) - - require.Equal(t, test.want, w.Code) - // Nothing reached accounts: the perimeter rejected it first. - require.Zero(t, mint.requestCount) - }) - } -} - -// accounts owns the authorization decision, so its refusal is the gateway's. -func TestGateway_ModuleRegistrationToken_RelaysAccountsRefusal(t *testing.T) { - gw, mint, _ := newExchangeHarness(t) - mint.code = codes.PermissionDenied - - w := httptest.NewRecorder() - gw.ServeHTTP(w, exchangeTokenRequest("documents", "wrong-secret", "test-internal-token")) - - require.Equal(t, http.StatusUnauthorized, w.Code) - require.Equal(t, 1, mint.requestCount) -} - -// A signing outage is not a refusal — a module must be able to tell them apart. -func TestGateway_ModuleRegistrationToken_UnavailableAuthority(t *testing.T) { - gw, mint, _ := newExchangeHarness(t) - mint.code = codes.Internal - - w := httptest.NewRecorder() - gw.ServeHTTP(w, exchangeTokenRequest("documents", "documents-secret", "test-internal-token")) - - require.Equal(t, http.StatusBadGateway, w.Code) -} - -// An issuer that cannot be reached is neither a refusal nor a broken issuer: it -// is transient, and the answer has to say so, or a module reads a restarting -// accounts as a secret it must re-provision. -func TestGateway_ModuleRegistrationToken_UnreachableAuthorityIsRetryable(t *testing.T) { - for _, code := range []codes.Code{codes.Unavailable, codes.DeadlineExceeded} { - t.Run(code.String(), func(t *testing.T) { - gw, mint, _ := newExchangeHarness(t) - mint.code = code - - w := httptest.NewRecorder() - gw.ServeHTTP(w, exchangeTokenRequest("documents", "documents-secret", "test-internal-token")) - - require.Equal(t, http.StatusServiceUnavailable, w.Code) - require.NotEmpty(t, w.Header().Get("Retry-After")) - require.Contains(t, w.Body.String(), "unavailable") - }) - } -} - -func TestGateway_ModuleRegistrationToken_MethodNotAllowed(t *testing.T) { - gw, _, _ := newExchangeHarness(t) - - req := httptest.NewRequest(http.MethodGet, moduleRegistrationTokenPath, nil) - req.Header.Set("X-Codefly-Internal-Token", "test-internal-token") - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusMethodNotAllowed, w.Code) -} - // The module secret is a credential the gateway consumes; like every other // codefly credential header it must never continue to an upstream. func TestGateway_ModuleSecretHeaderStrippedFromProxiedRequests(t *testing.T) { - gw, _, priv := newExchangeHarness(t) - moduleFake, upstream := newModuleUpstream(t) - - regResp := registerModule(t, gw, priv, "documents", upstream) - require.Equal(t, http.StatusOK, regResp.Code) + gw, apiFake, _, priv := newGatewayHarness(t) - req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) req.Header.Set(moduleSecretHeader, "documents-secret") w := httptest.NewRecorder() gw.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - require.Empty(t, moduleFake.lastHeaders.Get(moduleSecretHeader)) + require.Empty(t, apiFake.lastHeaders.Get(moduleSecretHeader)) } // ============================================================================ diff --git a/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go b/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go new file mode 100644 index 000000000..e24896274 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "sort" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" +) + +// installedEverythingRegistered is the entitlement authority the shared gateway +// harness runs with: it answers that the viewer's organization installed, and +// was granted, every solution the registry currently holds. +// +// It exists so the ROUTING tests stay about routing. A solution's availability +// and an organization's admission are three different layers (available, +// installed, exposed) and the proxy now consults all three, so a routing test +// that said nothing about admission would be asserting a 403. Answering from the +// registry keeps the fake faithful — it never admits a solution that is not +// there — while letting a test that is ABOUT admission substitute a narrower +// authority and get a real refusal. +type installedEverythingRegistered struct { + registry *fakeSolutionRegistry +} + +func (a *installedEverythingRegistered) List( + _ context.Context, _ *accountsv1.ListSolutionEntitlementsRequest, +) (*accountsv1.ListSolutionEntitlementsResponse, error) { + a.registry.mu.Lock() + // The entitled set is keyed on the TARGET each record is declared under, not + // on the alias: that is what the gateway compares, and a fake answering + // aliases would make every admission test green against the alias join this + // change exists to remove. + targets := make([]string, 0, len(a.registry.records)) + for _, record := range a.registry.records { + if record.GetTombstonedAt() != nil || record.GetDeclared().GetTargetId() == "" { + continue + } + targets = append(targets, record.GetDeclared().GetTargetId()) + } + a.registry.mu.Unlock() + sort.Strings(targets) + resp := &accountsv1.ListSolutionEntitlementsResponse{} + for _, target := range targets { + resp.Entitlements = append(resp.Entitlements, &accountsv1.SolutionEntitlement{ + TargetId: target, + InstallationId: "install-" + target, + RootScopeNodeId: "node-" + target, + Healthy: true, + }) + } + return resp, nil +} + +// entitledTo is an authority that admits exactly the named TARGETS, and +// records what it was asked — which is the point of most admission tests: the +// organization and viewer the authority is asked about must be the ones +// ext_authz verified. +type entitledTo struct { + ids []string + calls []*accountsv1.ListSolutionEntitlementsRequest +} + +func (a *entitledTo) List( + _ context.Context, req *accountsv1.ListSolutionEntitlementsRequest, +) (*accountsv1.ListSolutionEntitlementsResponse, error) { + a.calls = append(a.calls, req) + resp := &accountsv1.ListSolutionEntitlementsResponse{} + for _, id := range a.ids { + resp.Entitlements = append(resp.Entitlements, &accountsv1.SolutionEntitlement{ + TargetId: id, + InstallationId: "install-" + id, + RootScopeNodeId: "node-" + id, + Healthy: true, + }) + } + return resp, nil +} + +// revokerDuringCheck is a revoker whose lookup runs a hook, once, the first time +// ext_authz asks it about a token. +// +// It is how a test lands a registry change in the window the proxy used to take +// two resolutions in. ext_authz's revocation lookup is a REAL step on the +// production request path, between the proxy's resolution and the admission +// decision, so the change arrives where a reconcile tick or a heartbeat would — +// not through a seam built for the test, and not by faking either resolution. +type revokerDuringCheck struct { + during func() + fired bool +} + +func revokerDuring(during func()) *revokerDuringCheck { + return &revokerDuringCheck{during: during} +} + +func (r *revokerDuringCheck) Revoked(context.Context, string) (bool, error) { + if !r.fired { + r.fired = true + r.during() + } + return false, nil +} + +func (r *revokerDuringCheck) RevokedSession(context.Context, string) (bool, error) { + return false, nil +} + +func (r *revokerDuringCheck) Forget(string) {} diff --git a/module/services/auth-gateway/code/gateway_solution_admission_test.go b/module/services/auth-gateway/code/gateway_solution_admission_test.go new file mode 100644 index 000000000..e2c23cf5d --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_admission_test.go @@ -0,0 +1,597 @@ +package main + +import ( + "context" + "crypto/ed25519" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// The hole these tests close. +// +// A solution became reachable on this host because it was AVAILABLE — delivery +// declared it, or, until the cutover, its runtime registered it. Whether the +// caller's organization had installed it, and whether a grant reached the +// caller, decided only what the navigation menu and the surface listing showed. +// So a viewer in any organization could call the data endpoints of every +// solution the deployment ran, with a real bearer forwarded to them, by typing +// the path the menu declined to show. +// +// Three layers, none inferred from another: available on the deployment, +// installed by an organization, exposed to a team. The proxy is where the second +// and third are enforced for traffic, because it is the component that holds the +// verified identity and forwards the credential. +// +// Every test here answers 200 against the pre-change gateway. + +func TestGatewaySolutionProxy_RefusesASolutionTheOrganizationDidNotInstall(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + registerSolutionUpstream(t, gw, "reports") + // The organization installed `reports` and nothing else. `audit` is + // available on the deployment and reachable by path. + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("reports")}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Nil(t, fake.lastHeaders, + "an uninstalled solution must never receive the request, and never the caller's bearer") + require.Equal(t, refusalNotEntitled, w.Header().Get(solutionEntitlementRefusalHeader), + "the refusal must be named, so a client can tell 'nobody installed this' from an ext_authz denial") +} + +func TestGatewaySolutionProxy_AllowsASolutionTheOrganizationInstalled(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.NotNil(t, fake.lastHeaders, "an installed solution still routes") +} + +// The authority is asked about the identity ext_authz VERIFIED, never one the +// caller supplied. A proxy that read the caller's own X-Org-Id would let anyone +// admit themselves to every solution by editing a header. +func TestGatewaySolutionProxy_AsksAboutTheVerifiedIdentityNotTheCallersHeaders(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + authority := &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + gw.solutionEntitlements = authority + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + req.Header.Set("X-Org-Id", "00000000-0000-0000-0000-00000000dead") + req.Header.Set("X-User-Id", "00000000-0000-0000-0000-00000000beef") + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.NotEqual(t, "00000000-0000-0000-0000-00000000dead", authority.calls[0].GetOrgId(), + "the caller's own org header must never reach the authority") + require.NotEqual(t, "00000000-0000-0000-0000-00000000beef", authority.calls[0].GetSubjectId(), + "the caller's own user header must never reach the authority") +} + +// During an impersonation the admission is the IMPERSONATED viewer's — the +// subject accounts authorizes against — not the administrator's. Otherwise an +// administrator impersonating a user would reach solutions that user cannot, +// which is the one thing impersonation exists to check. +func TestGatewaySolutionProxy_ImpersonationIsDecidedOnTheImpersonatedViewer(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + authority := &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + gw.solutionEntitlements = authority + + actor := uuid.Must(uuid.NewV7()).String() + impersonated := uuid.Must(uuid.NewV7()).String() + org := uuid.Must(uuid.NewV7()).String() + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signImpersonationToken(t, priv, actor, impersonated, org)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.Equal(t, impersonated, authority.calls[0].GetSubjectId(), + "admission must be decided on the impersonated viewer, not the acting administrator") +} + +// "I cannot ask" is not "you may not". An accounts outage must not read as a +// missing grant, and must not route either. +func TestGatewaySolutionProxy_AuthorityFailureIs503AndForwardsNothing(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &fakeSolutionEntitlements{err: errors.New("accounts down")} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Nil(t, fake.lastHeaders, "nothing is forwarded while admission is unknown") + require.NotEqual(t, refusalNotEntitled, w.Header().Get(solutionEntitlementRefusalHeader), + "an outage must not be named as a verdict on the grant") +} + +// A deployment that never wired the authority fails closed. The behaviour this +// check replaces was "serve every solution to every organization", so an +// unwired client must not fall back to it. +func TestGatewaySolutionProxy_NoAuthorityClientFailsClosed(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = nil + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Nil(t, fake.lastHeaders) +} + +// A cursor that never terminates is undecidable, not "not entitled". A +// truncated set is indistinguishable from a smaller one, and here the +// difference is whether a viewer's traffic is refused. +func TestGatewaySolutionProxy_NonTerminatingCursorIs503(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &fakeSolutionEntitlements{pages: []*accountsv1.ListSolutionEntitlementsResponse{{ + Entitlements: []*accountsv1.SolutionEntitlement{{TargetId: fakeSolutionTarget("other")}}, + NextPageToken: "forever", + }}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// A solution past the first page is still installed. The walk stops at the first +// MATCH, never at the first page. +func TestGatewaySolutionProxy_FindsAnEntitlementOnALaterPage(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &fakeSolutionEntitlements{pages: []*accountsv1.ListSolutionEntitlementsResponse{ + { + Entitlements: []*accountsv1.SolutionEntitlement{{TargetId: fakeSolutionTarget("reports")}}, + NextPageToken: "page-2", + }, + { + Entitlements: []*accountsv1.SolutionEntitlement{{TargetId: fakeSolutionTarget("audit")}}, + }, + }} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +// An installed solution that is not serving right now stays a 503 from the +// registry's own resolve, and admission is never consulted for it: health and +// admission are different conditions and must not collapse into one answer. +func TestGatewaySolutionProxy_UnhealthyEntitlementIsStillAdmitted(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &fakeSolutionEntitlements{pages: []*accountsv1.ListSolutionEntitlementsResponse{{ + Entitlements: []*accountsv1.SolutionEntitlement{ + {TargetId: fakeSolutionTarget("audit"), Healthy: false}, + }, + }}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, + "an unhealthy installation is still installed; the registry's resolve owns liveness") +} + +// The public Module-Federation surface is not gated PER VIEWER, and cannot be: +// the browser's module loader fetches it with no credential, so there is no +// viewer to ask about. It is named here as a test rather than left as a comment, +// because a later change that gated it per viewer would break same-origin remote +// loading and the reason would not be obvious. +// +// This test's EXPECTATION CHANGED. It used to be named +// ...IsNotAdmissionGated and assert that this branch checked nothing at all, +// which pinned a hole: "no viewer to ask about" was read as "nothing is +// checkable", and a record nothing declared had its remote entry and chunks +// served same-origin and executed inside the host origin under its own +// `'self'` — while the authenticated surface beside it refused the very same +// record. The declaration half needs no viewer, so it is checked now (see the +// test below), and what remains pinned here is only the per-viewer half. +// +// What was deliberately NOT changed, stated so a reviewer can overrule it: a +// credential-less fetch is still served 200 with the authority not asked. There +// is no identity on the request to decide an organisation's admission with, and +// gating only the fetches that happen to carry one adds nothing — an attacker +// omits the credential — while 503-ing a solution's own asset loads during an +// accounts outage. +func TestGatewaySolutionProxy_PublicAssetSurfaceIsNotGatedPerViewer(t *testing.T) { + gw, _, _, _ := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "example-go") + authority := &entitledTo{ids: nil} + gw.solutionEntitlements = authority + + req := httptest.NewRequest(http.MethodGet, "/solutions/example-go/assets/mf-manifest.json", nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.NotNil(t, fake.lastHeaders) + require.Empty(t, authority.calls, + "an unauthenticated asset fetch has no viewer, so the authority is not asked") +} + +// The declaration half of the same branch, which needs no viewer. +// +// A registration no declaration opened a target for cannot have been installed +// by any organisation, so nobody consented to this host serving its bytes. The +// authenticated surface has refused such a record since the target join landed; +// the public surface served it, same-origin, as a script the host origin +// executes. +// +// Against the pre-change gateway this answers 200 and the upstream is reached. +func TestGatewaySolutionProxy_PublicAssetSurfaceRefusesPresenceNothingDeclared(t *testing.T) { + gw, _, _, _ := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "example-go") + solutionRegistryFake(t, gw).undeclare("example-go") + require.NoError(t, gw.solutions.refresh(context.Background())) + authority := &entitledTo{ids: nil} + gw.solutionEntitlements = authority + + for _, path := range []string{ + "/assets/mf-manifest.json", + "/assets/remoteEntry.js", + "/.well-known/capabilities", + } { + req := httptest.NewRequest(http.MethodGet, "/solutions/example-go"+path, nil) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code, + "%q: presence nothing declared must serve no bytes through this host", path) + } + require.Nil(t, fake.lastHeaders, + "an undeclared solution must never be reached, not even for its static bytes") + require.Empty(t, authority.calls, + "and the refusal is final without asking the authority: no viewer, and no target to ask about") +} + +// A session that has selected no organization has no org-scoped admission to +// read. That is refused rather than routed: routing it would reach a solution on +// nobody's installation, and answering it as "not entitled" would send someone +// looking for a grant when what they need is to pick an organization. +func TestGatewaySolutionProxy_NoOrganizationInSessionIsRefused(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + authority := &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + gw.solutionEntitlements = authority + + orgless := signAccessToken(t, priv, accessKeyID(priv.Public().(ed25519.PublicKey)), accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: uuid.Must(uuid.NewV7()).String(), + Audience: jwt.ClaimStrings{"saas-starter"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti-orgless", + }, + SessionID: uuid.Must(uuid.NewV7()).String(), + }) + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+orgless) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, refusalNoOrganization, w.Header().Get(solutionEntitlementRefusalHeader)) + require.Nil(t, fake.lastHeaders) + require.Empty(t, authority.calls, "there is no org-scoped set to ask about") +} + +// The §9 attack, executed rather than described: a REPLACEMENT binding that +// claims a withdrawn route alias must inherit nothing from the installation that +// named it. +// +// The previous shape of this check compared the alias an installation named to +// the alias being requested. Both are the string "reports" here, so it admitted +// org A's viewers to binding Y and forwarded their bearers to it — with no +// administrator having acted, and with A's team grant now exposing Y. +// +// Against the pre-change gateway this test answers 200. It fails for the reason +// it exists rather than because the entitlement shape changed: the authority +// still answers an entitlement for the predecessor, the alias still resolves, +// the upstream is still live, and the only thing that differs is the identity +// behind the alias. +func TestGatewaySolutionProxy_AReplacementBindingDoesNotInheritTheWithdrawnInstallation(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "reports") + + // Org A installed the binding that held `reports`, and still holds that + // entitlement: an installation is not withdrawn by delivery, it is revoked — + // and this test is about the instant before any revocation has been seen. + withdrawn := fakeSolutionTarget("reports") + gw.solutionEntitlements = &entitledTo{ids: []string{withdrawn}} + + // A different binding now serves the same alias. Its target is its own. + replacement := "target-replacement-binding" + require.NotEqual(t, withdrawn, replacement) + solutionRegistryFake(t, gw).declareTarget("reports", "acme.test.replacement", replacement) + require.NoError(t, gw.solutions.refresh(context.Background())) + + req := httptest.NewRequest(http.MethodGet, "/solutions/reports/v1/reports/data", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code, + "a replacement binding must not be reachable through the predecessor's installation") + require.Nil(t, fake.lastHeaders, + "the replacement must never receive the request, and never the caller's bearer") + require.Equal(t, refusalNotEntitled, w.Header().Get(solutionEntitlementRefusalHeader)) +} + +// The other direction of the same rule, so the test above cannot pass by +// refusing everything: the organisation that installed the REPLACEMENT reaches +// it, on the same alias, in the same registry state. +func TestGatewaySolutionProxy_TheReplacementsOwnInstallationIsAdmitted(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "reports") + + replacement := "target-replacement-binding" + solutionRegistryFake(t, gw).declareTarget("reports", "acme.test.replacement", replacement) + require.NoError(t, gw.solutions.refresh(context.Background())) + gw.solutionEntitlements = &entitledTo{ids: []string{replacement}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/reports/v1/reports/data", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.NotNil(t, fake.lastHeaders) +} + +// Presence that nothing declared is admissible to NOBODY. There is no target for +// an installation to have named, so there is no organisation whose consent could +// admit it — and that is a verdict (403), not an outage (503): the authority was +// reachable and the answer is final. +// +// Against the pre-change gateway this answers 200, because a self-registered +// record was routable and its alias was the join key. +func TestGatewaySolutionProxy_RefusesPresenceNothingDeclared(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + fake := registerSolutionUpstream(t, gw, "audit") + solutionRegistryFake(t, gw).undeclare("audit") + require.NoError(t, gw.solutions.refresh(context.Background())) + // Entitled to the alias's derived target, which is what an installation made + // before the declaration was stripped would have named. + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Nil(t, fake.lastHeaders) + require.Equal(t, refusalNotEntitled, w.Header().Get(solutionEntitlementRefusalHeader)) +} + +// A REFUSED request must cost the org's budget, because asking is what costs. +// +// Admission is an authority call to accounts — up to one scope-tree read per +// entitlement page — and that cost is identical whether the answer forwards the +// request or refuses it. With the limiter only in front of the proxy, a caller +// could ask about a solution it was not entitled to as fast as it liked: every +// refusal was free, so there was nothing to exhaust and the authority absorbed +// the whole load. +// +// The assertion is on the AUTHORITY's call count, not on the response code: a +// 429 proves the limiter ran, but only a bounded number of authority calls +// proves the limiter ran BEFORE it. +func TestGatewaySolutionProxy_RefusedRequestsAreMeteredBeforeTheAuthorityIsAsked(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + // Entitled to nothing, so every request is refused — the free path. + authority := &entitledTo{ids: nil} + gw.solutionEntitlements = authority + // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. + gw.rateLimiter = NewRateLimiter(1) + + token := signValidToken(t, priv) + throttled := false + for i := 0; i < 8; i++ { + req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/audit/logs", nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + if w.Code == http.StatusTooManyRequests { + throttled = true + continue + } + require.Equal(t, http.StatusForbidden, w.Code) + } + require.True(t, throttled, "refused solution requests must consume the budget") + require.LessOrEqual(t, len(authority.calls), 2, + "the authority must be asked only for requests the budget admitted: got %d calls for 8 requests", + len(authority.calls)) +} + +// An outage is not a verdict about a solution, and the registry read is part of +// the admission decision. +// +// The on-demand registry read was issued and its error LOGGED AND DROPPED, after +// which the empty lookup that followed it was reported as a fact: "no such +// solution" (502 to the proxy) and, through the admission entry point, "your +// organization did not install this" (403, named `not-entitled`). Both are +// absence of evidence reported as evidence of absence, and the 403 is the worse +// of the two — it sends an operator to the installation and the grants over an +// accounts outage. +func TestGatewaySolutionProxy_RegistryOutageOnAnUnknownAliasIsNotAVerdict(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + // A snapshot HAS loaded — this is not a cold replica — and it does not hold + // `reports`. + registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + // The read that would settle whether `reports` exists now fails. + solutionRegistryFake(t, gw).listErr = errors.New("accounts down") + // Past the on-demand refresh floor, so the miss really does issue a read. + gw.solutions.now = func() time.Time { return time.Now().Add(time.Second) } + + req := httptest.NewRequest(http.MethodGet, "/solutions/reports/v1/reports/data", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code, + "a registry read that failed must answer 'cannot tell', never 'no such solution'") + require.NotEqual(t, refusalNotEntitled, w.Header().Get(solutionEntitlementRefusalHeader)) +} + +// The SECOND request of a burst must answer the same thing as the first. +// +// A miss issues at most one registry read per refresh floor, so the requests +// that arrive inside that window decide on a read they did not issue. Reporting +// "nothing to do" for a suppressed read drew a clean bill of health from the +// failed one before it: the first request in a burst answered 503 and every +// other one answered 502, for the same alias, in the same outage. +func TestGatewaySolutionProxy_RegistryOutageStaysUndecidableInsideTheRefreshFloor(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + registerSolutionUpstream(t, gw, "audit") + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("audit")}} + registry := solutionRegistryFake(t, gw) + registry.listErr = errors.New("accounts down") + // A frozen clock: the first miss is past the floor, and every request after + // it is inside it — the burst, with no sleeping. + frozen := time.Now().Add(time.Second) + gw.solutions.now = func() time.Time { return frozen } + + token := signValidToken(t, priv) + for attempt := 0; attempt < 3; attempt++ { + req := httptest.NewRequest(http.MethodGet, "/solutions/reports/v1/reports/data", nil) + req.Header.Set("authorization", "Bearer "+token) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusServiceUnavailable, w.Code, + "attempt %d: a suppressed read must report the outage it suppressed", attempt) + } + require.Equal(t, 1, registry.listCalls-1, + "the floor must still hold the reads down to one: this is about the ANSWER, not the rate") +} + +// replacementArrivesMidRequest wires a solution whose alias is taken over by a +// REPLACEMENT binding — new target, new address — part-way through one request. +// +// The takeover runs inside ext_authz's revocation lookup, which is a real step +// on the production request path between the proxy's resolution of the upstream +// and its admission decision. That is the window the two reads straddled: a +// reconcile tick, a lease lapse or a declared record's heartbeat lands there in +// production, and nothing about the request is faked to put it there. +// +// It returns the two upstreams: the one the record named when the request +// arrived, and the one it names by the time admission runs. +func replacementArrivesMidRequest( + t *testing.T, gw *Gateway, alias, replacementTarget string, +) (predecessor, replacement *fakeUpstream) { + t.Helper() + predecessor = registerSolutionUpstream(t, gw, alias) + replacement = &fakeUpstream{body: "replacement-response"} + replacementSrv := httptest.NewServer(replacement) + t.Cleanup(replacementSrv.Close) + + registry := solutionRegistryFake(t, gw) + gw.authz.revoker = revokerDuring(func() { + registry.repointUpstream(alias, replacementSrv.URL) + registry.declareTarget(alias, "acme.test.replacement", replacementTarget) + require.NoError(t, gw.solutions.refresh(context.Background())) + }) + return predecessor, replacement +} + +// The identity admitted and the address reached must come from ONE resolution. +// +// The proxy took the upstream from one read of the registry snapshot and the +// target from another, further down the same request. A replacement binding +// claiming the alias in between was then admitted on ITS installation — the +// second read saw its target, and the requesting organisation holds it — while +// the bearer was forwarded to the address the FIRST read returned, which is the +// predecessor's. An organisation reached a binding it had not installed, and the +// predecessor received a real bearer for a viewer nobody had granted it to. +// +// Against the two-read gateway this answers 200 and the predecessor's capture is +// populated. +func TestGatewaySolutionProxy_OneResolutionDecidesAdmissionAndDestination(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + const replacementTarget = "target-replacement-binding" + predecessor, replacement := replacementArrivesMidRequest(t, gw, "reports", replacementTarget) + // The caller's organisation installed the REPLACEMENT and nothing else. + gw.solutionEntitlements = &entitledTo{ids: []string{replacementTarget}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/reports/v1/reports/data", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code, + "the resolution this request was admitted against is the predecessor's, which this organisation did not install") + require.Nil(t, predecessor.lastHeaders, + "the predecessor must never receive a request admitted on the replacement's installation, and never the bearer") + require.Nil(t, replacement.lastHeaders, + "nor the replacement, which this request never resolved") +} + +// The other direction of the same rule, so the test above cannot pass by +// refusing whatever changes under it: with the SAME takeover landing in the same +// window, the organisation that installed the solution this request RESOLVED is +// admitted, and the request reaches that one's address. +// +// Against the two-read gateway this answers 403: the second read had already +// moved on to the replacement's target, which this organisation does not hold. +func TestGatewaySolutionProxy_TheResolvedBindingIsTheOneReachedWhenTheAliasMovesMidRequest(t *testing.T) { + gw, _, _, priv := newGatewayHarness(t) + predecessor, replacement := replacementArrivesMidRequest(t, gw, "reports", "target-replacement-binding") + // The caller's organisation installed the binding this request resolved. + gw.solutionEntitlements = &entitledTo{ids: []string{fakeSolutionTarget("reports")}} + + req := httptest.NewRequest(http.MethodGet, "/solutions/reports/v1/reports/data", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.NotNil(t, predecessor.lastHeaders, + "the address reached must be the one whose identity was admitted") + require.Nil(t, replacement.lastHeaders, + "and never the binding that took the alias after this request had resolved it") +} diff --git a/module/services/auth-gateway/code/gateway_solution_boundary_test.go b/module/services/auth-gateway/code/gateway_solution_boundary_test.go index bfa8e91cb..a3eee2303 100644 --- a/module/services/auth-gateway/code/gateway_solution_boundary_test.go +++ b/module/services/auth-gateway/code/gateway_solution_boundary_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/ed25519" "net/http" "net/http/httptest" @@ -28,142 +29,89 @@ func mintRequest(t *testing.T, priv ed25519.PrivateKey) *http.Request { return req } -// A verified credential on the mint becomes the trusted solution identity, and -// only on the mint: the same credential on any other route stamps nothing, so -// the surface it widens is one procedure. -func TestGateway_WorkContextMint_StampsTheVerifiedSolution(t *testing.T) { - gw, apiFake, _, priv := newGatewayHarness(t) - - req := mintRequest(t, priv) - req.Header.Set(solutionRegistrationHeader, - signSolutionRegistration(t, "audit", "solution:audit")) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "audit", apiFake.lastHeaders.Get(solutionIdentityHeader)) - // The publisher travels with the id, from the same claims: accounts checks - // it against the publisher that owns the registration. - require.Equal(t, "solution:audit", apiFake.lastHeaders.Get(solutionPublisherHeader)) - // The credential itself never reaches accounts: it proves the identity here - // and the header is what accounts reads. - require.Empty(t, apiFake.lastHeaders.Get(solutionRegistrationHeader)) - - other := httptest.NewRequest(http.MethodGet, "/v1/users", nil) - other.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - other.Header.Set(solutionRegistrationHeader, - signSolutionRegistration(t, "audit", "solution:audit")) - w = httptest.NewRecorder() - gw.ServeHTTP(w, other) +// accountsSolutionIdentityHeader and accountsSolutionPublisherHeader are +// ACCOUNTS' spellings, written here as literals ON PURPOSE. The point of this +// file is to check the gateway against what accounts reads, so it must not share +// a symbol with the gateway: an earlier version of this test set and asserted +// `x-codefly-solution-identity`, the same near-miss the strip list carried, so +// the test and the hole agreed and the suite was green while any authenticated +// caller could assert any solution's identity to accounts. A test that borrows +// the constant it is checking cannot catch a wrong constant. +// +// They are accounts' `connect_auth_interceptor.go` constants, lowercased, which +// is the form `http.Header.Del` and gRPC metadata both use. +const ( + accountsSolutionIdentityHeader = "x-codefly-solution-id" + accountsSolutionPublisherHeader = "x-codefly-solution-publisher" +) - require.Equal(t, http.StatusOK, w.Code) - require.Empty(t, apiFake.lastHeaders.Get(solutionIdentityHeader), - "the credential means nothing outside the mint procedure") +// The gateway's strip constants must BE accounts' spellings. `http.Header.Del` +// canonicalises its argument, so a near-miss deletes a header nobody sends and +// leaves the real one in place, with no error anywhere. +func TestSolutionStripConstantsAreTheHeadersAccountsReads(t *testing.T) { + require.Equal(t, accountsSolutionIdentityHeader, solutionIdentityHeader) + require.Equal(t, accountsSolutionPublisherHeader, solutionPublisherHeader) } // A caller that simply asserts the header gets it stripped, in both spellings // an upstream could read one under. This is the whole cross-solution property: // without the strip, any authenticated viewer could mint under any solution's -// boundary by typing its id. +// boundary by typing its id. Asserted on what the UPSTREAM received, because +// that is the only place the question is answered. func TestGateway_WorkContextMint_StripsACallerAssertedSolution(t *testing.T) { gw, apiFake, _, priv := newGatewayHarness(t) req := mintRequest(t, priv) - req.Header.Set(solutionIdentityHeader, "audit") - req.Header.Set(solutionPublisherHeader, "solution:audit") + req.Header.Set(accountsSolutionIdentityHeader, "audit") + req.Header.Set(accountsSolutionPublisherHeader, "solution:audit") + // The canonical-cased spelling too: a caller controls the case on the wire, + // and Del is case-insensitive, so both must go. + req.Header.Set("X-Codefly-Solution-Id", "audit") req.Header.Set("Grpc-Metadata-X-Codefly-Solution-Id", "audit") w := httptest.NewRecorder() gw.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) - require.Empty(t, apiFake.lastHeaders.Get(solutionIdentityHeader)) - require.Empty(t, apiFake.lastHeaders.Get(solutionPublisherHeader)) + require.Empty(t, apiFake.lastHeaders.Get(accountsSolutionIdentityHeader), + "a caller-asserted solution identity reached the upstream: accounts trusts this header beside a valid gateway token, so it would mint under that solution's boundary") + require.Empty(t, apiFake.lastHeaders.Get(accountsSolutionPublisherHeader)) requireNoGRPCMetadataHeaders(t, apiFake.lastHeaders) } -// A caller holding one solution's credential cannot stamp another's: the id is -// the credential's own claim, so the header says what was proved and the -// request body has no say at all. -func TestGateway_WorkContextMint_CredentialDecidesTheSolution(t *testing.T) { - gw, apiFake, _, priv := newGatewayHarness(t) - - req := mintRequest(t, priv) - req.Header.Set(solutionRegistrationHeader, - signSolutionRegistration(t, "example-b", "solution:example-b")) - req.Header.Set(solutionIdentityHeader, "example-a") - req.Header.Set(solutionPublisherHeader, "solution:example-a") - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "example-b", apiFake.lastHeaders.Get(solutionIdentityHeader)) - require.Equal(t, "solution:example-b", apiFake.lastHeaders.Get(solutionPublisherHeader)) -} - -// A presented credential that does not verify is refused rather than treated as -// an ordinary mint. Falling through would answer a forged or expired credential -// with a capability under a different boundary, which is the one outcome worth -// failing loudly on. -func TestGateway_WorkContextMint_RefusesAnUnverifiableCredential(t *testing.T) { - gw, apiFake, _, priv := newGatewayHarness(t) - - for name, credential := range map[string]string{ - "not a token": "not-a-token", - "wrong audience": signValidToken(t, priv), - "empty solution": signSolutionRegistration(t, "", "solution:audit"), - "invalid solution": signSolutionRegistration(t, "Not A Solution", "solution:audit"), - "no publisher": signSolutionRegistration(t, "audit", ""), - } { - t.Run(name, func(t *testing.T) { - req := mintRequest(t, priv) - req.Header.Set(solutionRegistrationHeader, credential) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - - require.Equal(t, http.StatusUnauthorized, w.Code) - require.Empty(t, apiFake.lastHeaders.Get(solutionIdentityHeader)) - }) - } -} - -// A mint with no credential is an ordinary mint and is untouched — every mint -// the host's own pages and every composed module make takes this path. -func TestGateway_WorkContextMint_WithoutACredentialIsUnchanged(t *testing.T) { - gw, apiFake, _, priv := newGatewayHarness(t) - - w := httptest.NewRecorder() - gw.ServeHTTP(w, mintRequest(t, priv)) - - require.Equal(t, http.StatusOK, w.Code) - require.Empty(t, apiFake.lastHeaders.Get(solutionIdentityHeader)) -} - // A registration answer carries NO boundary, to either half, and neither does // the registry snapshot. A solution never needs one — accounts derives and // seals it from the credential the solution already presents — so echoing it // would only widen who can see a value that is now stable for the life of the // registration. The accounts half of this promise is // TestSolutionRegistrationResponsesCarryNoRuntimeBoundary. -func TestGateway_SolutionRegistration_EchoesNoBoundary(t *testing.T) { +// The registry must never echo a runtime boundary, and this is the half of that +// guarantee that survives the cutover. +// +// It used to POST to `/solutions/_frontend` and `/solutions/_register` and check +// their answers too. Those endpoints are the runtime registration writer and are +// deleted, so asserting on their responses would assert on a 404. What still +// matters — and matters more, since the seed now outlives a withdrawal — is that +// the SNAPSHOT every consumer reads carries no boundary: the seed is the one +// thing that must never leave this host, and the snapshot is what leaves it. +func TestGateway_SolutionRegistry_SnapshotEchoesNoBoundary(t *testing.T) { + // A snapshot that has actually LOADED, reached through the declared path. + // This test used to POST to `/solutions/_frontend` and `/solutions/_register` + // to populate the registry; those are the runtime registration writer and + // are deleted. `refresh` is the seam that remains — without it the handler + // answers 503 "solution registry unavailable" and the boundary assertion + // would pass because there is nothing to leak. gw, _, _, _ := newGatewayHarness(t) + registry := solutionRegistryFake(t, gw) + registry.seedDeclared("audit", "http://audit.svc") + require.NoError(t, gw.solutions.refresh(context.Background())) - srv := httptest.NewServer(&fakeUpstream{body: "solution-response"}) - t.Cleanup(srv.Close) - - for name, call := range map[string][2]string{ - "the frontend half": {"/solutions/_frontend", `{"id":"audit","manifest":"{\"id\":\"audit\"}"}`}, - "the backend half": {"/solutions/_register", `{"id":"audit","upstream":"` + srv.URL + `"}`}, - } { - t.Run(name, func(t *testing.T) { - answer := postSolutionRegistration(t, gw, call[0], call[1], http.StatusOK) - require.NotContains(t, answer, "runtimeBoundary") - }) - } - - snapshot := httptest.NewRequest(http.MethodGet, "/solutions/_registry", nil) - snapshot.Header.Set("X-Codefly-Internal-Token", "test-internal-token") + req := httptest.NewRequest(http.MethodGet, "/solutions/_registry", nil) + req.Header.Set("X-Codefly-Internal-Token", "test-internal-token") w := httptest.NewRecorder() - gw.ServeHTTP(w, snapshot) - require.Equal(t, http.StatusOK, w.Code) + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, + "the snapshot must actually be served, or the assertion below is vacuous") + require.Contains(t, w.Body.String(), "audit", + "the snapshot must carry the declared record, or there is nothing a boundary could leak through") require.NotContains(t, strings.ToLower(w.Body.String()), "boundary") } diff --git a/module/services/auth-gateway/code/gateway_solution_credential.go b/module/services/auth-gateway/code/gateway_solution_credential.go deleted file mode 100644 index 9890a42d6..000000000 --- a/module/services/auth-gateway/code/gateway_solution_credential.go +++ /dev/null @@ -1,344 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sync" - "time" - - accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - - "github.com/golang-jwt/jwt/v5" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -// --- Per-solution cryptographic identity --- -// -// Solution registration is bound to a publisher the same way module federation -// is (see gateway_modules.go), and for a strictly stronger reason. A module -// registration decides where /v1//* traffic goes; a solution -// registration ALSO decides which script the frontend loads as a -// Module-Federation remote — code that then runs in the host origin with the -// viewer's access token. So the two halves, gateway and frontend, accept the -// SAME credential: one owner-bound authority contract, verified independently on -// each side, rather than a shared secret that says nothing about who is calling. - -// solutionRegisterSuffix and solutionRegistrationTokenSuffix are the reserved -// sub-paths of /solutions/. Neither is a possible solution id: validCatalogIdentity -// rejects the leading underscore, so no registrant can shadow them. -const solutionRegistrationTokenSegment = "_registration-token" - -// solutionServicePrefix marks the RouteEntry.Service of a registered solution -// route, so proxyTo can tell a runtime-registered upstream from a catalog one. -const solutionServicePrefix = "solution:" - -// solutionRegistrationHeader carries the signed per-solution registration token. -const solutionRegistrationHeader = "X-Codefly-Solution-Registration" - -// solutionIdentityHeader and solutionPublisherHeader are this gateway's own -// assertion of which registered solution a request mints a Work Context for, -// and of the publisher its credential names (issue #1015). Both are stamped -// only from a verified solution-registration credential, only on the mint -// procedure, and only beside the gateway credential accounts believes; both are -// in untrustedAuthHeaders, so any inbound spelling is stripped first. They are -// lowercase because that is the form both the strip set and gRPC metadata use. -// -// The publisher travels with the id because accounts checks it against the -// publisher of record: a secret re-provisioned to a different publisher must -// not mint the boundary of a registration it cannot write. -const ( - solutionIdentityHeader = "x-codefly-solution-id" - solutionPublisherHeader = "x-codefly-solution-publisher" -) - -// workContextMintProcedure is the Work Context mint. It is the only procedure -// outside the registration surface on which a solution's registration -// credential means anything, because it is the only one whose answer depends on -// which solution is asking. -const workContextMintProcedure = "/saas.accounts.v1.WorkContextService/StartTask" - -// solutionSecretHeader carries the solution's own registration secret. The -// gateway consumes it in the exchange and forwards it only on the internal leg, -// to accounts, which holds the digest to compare it against. -const solutionSecretHeader = "X-Codefly-Solution-Secret" - -// solutionRegistrationAudience scopes a solution-registration token to this -// single purpose. Access tokens carry aud "saas-starter" and module-registration -// tokens carry "module-registration"; even though all three may be signed with -// the same key, the audience check makes them non-interchangeable — an access -// token cannot register a solution, and a module credential cannot reach the -// authority to publish host-origin code. -const solutionRegistrationAudience = "solution-registration" - -// solutionRegisterMaxBytes bounds a registration body. The payload is two short -// strings; anything larger is a malformed or hostile caller, and reading it is -// work an unauthenticated-at-parse-time endpoint should not do. -const solutionRegisterMaxBytes = 4096 - -// mintSolutionRegistrationMethod is accounts' solution-credential exchange. Like -// its module counterpart it is EXPOSURE_INTERNAL, so the generated mesh policy -// admits this gateway's service account to exactly this path. -const mintSolutionRegistrationMethod = "/saas.accounts.v1.ModuleCapabilitiesService/MintSolutionRegistration" - -// solutionRegistrationClaims is the signed assertion a solution presents. `sub` -// is the publisher identity that owns the registration; Solution is the single -// catalog-identity segment that identity may register, update, or delete. -type solutionRegistrationClaims struct { - jwt.RegisteredClaims - Solution string `json:"solution"` -} - -// verifySolutionRegistration parses and validates a solution registration token -// with the same alg-locked Ed25519 discipline as an access token — same -// published key set selected by the token's kid, plus issuer, expiry, and this -// solution-registration audience. It fails closed: a nil check, an unreachable -// or unrecognised key, an empty or malformed token, a wrong or absent audience, -// a missing jti, or an expired token all yield ok=false. -func (s *ExtAuthz) verifySolutionRegistration(ctx context.Context, tokenString string) (*solutionRegistrationClaims, bool) { - if s == nil || s.keys == nil || tokenString == "" { - return nil, false - } - claims := &solutionRegistrationClaims{} - parser := jwt.NewParser( - jwt.WithValidMethods([]string{"EdDSA"}), - jwt.WithIssuer(s.issuer), - jwt.WithAudience(solutionRegistrationAudience), - jwt.WithExpirationRequired(), - jwt.WithLeeway(tokenClockSkewLeeway), - ) - token, err := parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { - if t.Method.Alg() != "EdDSA" { - return nil, fmt.Errorf("alg forbidden: %s", t.Method.Alg()) - } - keyID, _ := t.Header["kid"].(string) - return s.keys.keyFor(ctx, keyID) - }) - if err != nil || !token.Valid { - return nil, false - } - // Single-use enforcement keys on the jti, so a token without one is not - // replay-protectable and is refused rather than admitted unprotected. - if claims.ID == "" || claims.ExpiresAt == nil || claims.Subject == "" { - return nil, false - } - return claims, true -} - -// mintSolutionRegistration runs the internal leg over the existing accounts -// connection, presenting the gateway's own cluster-internal credential. -func (s *ExtAuthz) mintSolutionRegistration( - ctx context.Context, solutionID, secret string, -) (*accountsv1.SolutionMintRegistrationResponse, error) { - if s.backendConn == nil { - return nil, fmt.Errorf("accounts connection not configured") - } - ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", s.internalToken) - response := &accountsv1.SolutionMintRegistrationResponse{} - request := &accountsv1.SolutionMintRegistrationRequest{SolutionId: solutionID, Secret: secret} - if err := s.backendConn.Invoke(ctx, mintSolutionRegistrationMethod, request, response); err != nil { - return nil, err - } - return response, nil -} - -// --- Registration replay protection --- - -// registrationReplayGuard makes each registration credential single-use. A token -// is minted per attempt and lives five minutes, so the set it has to remember is -// small and self-clearing: an entry is dropped once the token it names could no -// longer verify anyway. -type registrationReplayGuard struct { - mu sync.Mutex - used map[string]time.Time - now func() time.Time -} - -func newRegistrationReplayGuard() *registrationReplayGuard { - return ®istrationReplayGuard{used: map[string]time.Time{}, now: time.Now} -} - -// consume records a first use of jti and reports true. A second call for the -// same jti reports false while the original token could still verify. -// -// Expired entries are swept on each call rather than by a timer: the sweep is -// bounded by how many credentials were issued in one token lifetime, and a guard -// nobody calls holds nothing worth reclaiming. -func (g *registrationReplayGuard) consume(jti string, expiresAt time.Time) bool { - if jti == "" { - return false - } - g.mu.Lock() - defer g.mu.Unlock() - now := g.now() - for seen, expiry := range g.used { - // The verifier admits a token up to tokenClockSkewLeeway past exp, so an - // entry has to outlive its token by the same margin to stay useful. - if now.After(expiry.Add(tokenClockSkewLeeway)) { - delete(g.used, seen) - } - } - if _, replayed := g.used[jti]; replayed { - return false - } - g.used[jti] = expiresAt - return true -} - -// handleSolutionRegistrationToken serves POST /solutions/_registration-token, -// the exchange a solution runs immediately before registering: it presents the -// registration secret its composition gave it and receives the signed, -// solution-bound token both this surface and the frontend require. -// -// It is the exact counterpart of the module handshake, and brokers for the same -// reason: the gateway holds only the public half of the signing key, and a -// solution cannot reach accounts' internal listener itself. -func (g *Gateway) handleSolutionRegistrationToken(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - httpError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - // Perimeter check, as on the module exchange: the solution secret alone - // identifies the solution, but this listener does not rate-limit unrouted - // paths, so guessing one must first cost an attacker the shared credential. - if g.authz == nil || !g.authz.acceptsInternalToken(r.Header.Get("X-Codefly-Internal-Token")) { - httpError(w, http.StatusUnauthorized, "unauthorized") - return - } - secret := r.Header.Get(solutionSecretHeader) - if secret == "" { - httpError(w, http.StatusUnauthorized, "unauthorized") - return - } - var payload struct { - ID string `json:"id"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, solutionRegisterMaxBytes)).Decode(&payload); err != nil { - httpError(w, http.StatusBadRequest, "invalid json") - return - } - if !validCatalogIdentity(payload.ID) { - httpError(w, http.StatusBadRequest, "invalid id") - return - } - - ctx, cancel := context.WithTimeout(r.Context(), moduleRegistrationExchangeTimeout) - defer cancel() - issued, err := g.authz.mintSolutionRegistration(ctx, payload.ID, secret) - if err != nil { - writeRegistrationExchangeFailure(w, err) - return - } - body, err := json.Marshal(map[string]string{ - "token": issued.GetToken(), - "expiresAt": issued.GetExpiresAt().AsTime().UTC().Format(time.RFC3339), - }) - if err != nil { - httpError(w, http.StatusBadGateway, "registration token unavailable") - return - } - w.Header().Set("content-type", "application/json") - w.Header().Set("cache-control", "no-store") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) -} - -// writeRegistrationExchangeFailure answers a credential exchange (module or -// solution) that accounts did not complete, keeping three outcomes apart that a -// registrant has to act on differently: -// -// - 401: accounts refused the secret. It answers an undeclared identity and a -// wrong secret identically, so relaying its refusal reveals nothing about -// what a composition declared — and this is the ONLY answer that means -// "check provisioning". -// - 503 with Retry-After: accounts could not be reached or did not answer in -// time — a rollout, a restart, a network partition. Nothing about the -// registrant is wrong and the next attempt may succeed; reporting it as a -// refusal sends an operator to re-provision a secret that was correct. -// - 502: accounts answered with an error that is neither (it failed to sign, -// or to record the mint). The issuer is broken, not the registrant. -func writeRegistrationExchangeFailure(w http.ResponseWriter, err error) { - switch status.Code(err) { - case codes.PermissionDenied: - httpError(w, http.StatusUnauthorized, "unauthorized") - case codes.Unavailable, codes.DeadlineExceeded: - w.Header().Set("Retry-After", registrationExchangeRetryAfter) - httpError(w, http.StatusServiceUnavailable, "registration token issuer unavailable: retry") - default: - httpError(w, http.StatusBadGateway, "registration token issuer failed") - } -} - -// registrationExchangeRetryAfter is the Retry-After, in seconds, on an exchange -// the issuer could not serve. A registrant's heartbeat backs off on its own; this -// only tells one that does not when trying again is reasonable. -const registrationExchangeRetryAfter = "5" - -// authorizeSolutionRegistration verifies the presented registration credential -// and burns its single use. It writes the refusal itself and reports ok=false, -// so both mutations share one authentication path. -func (g *Gateway) authorizeSolutionRegistration(w http.ResponseWriter, r *http.Request) (*solutionRegistrationClaims, bool) { - claims, ok := g.authz.verifySolutionRegistration(r.Context(), r.Header.Get(solutionRegistrationHeader)) - if !ok { - httpError(w, http.StatusUnauthorized, "unauthorized") - return nil, false - } - // A registration credential is fetched per attempt and presented once. Burn - // its jti so a copy captured in transit or in a log cannot be replayed inside - // its remaining lifetime to re-point a route after the legitimate registrant - // has moved on. - if !g.registrationReplay.consume(claims.ID, claims.ExpiresAt.Time) { - httpError(w, http.StatusUnauthorized, "unauthorized") - return nil, false - } - return claims, true -} - -// verifiedSolutionMint reports which registered solution is minting a Work -// Context on this request, and whether a presented credential was refused. -// -// A solution's runs are reachable only under the boundary of the context that -// admitted them, so a solution needs a boundary that outlives one context — and -// a boundary it could choose would let one solution mint for another's and read -// its runs (issue #1015). The host therefore assigns one per registration, and -// this is where "which solution" is established: from the solution's own -// signed, solution-bound registration credential, the same one both -// registration halves take, verified against the published key set with the -// same alg-locked discipline. Nothing in the request body is consulted, and the -// caller's own spelling of the stamped header was already stripped. -// -// A presented credential that does not verify is a refusal, not an ordinary -// mint: falling back would answer a broken or replayed credential with a -// capability under a different boundary, which is the one outcome worth being -// loud about. A request that presents none is an ordinary mint and is untouched -// — that is every mint the host's own pages and every composed module make. -// -// Unlike authorizeSolutionRegistration this does NOT burn the credential's -// jti. That guard exists so a captured credential cannot re-point a route or -// replace a remote after the legitimate registrant has moved on; a mint changes -// no state a replay could steer, and the passthrough mints per audience and -// scope set, so burning it would force a fresh credential exchange — an audited -// mint on accounts — several times per page. The credential's five-minute life -// is the bound here. -func (g *Gateway) verifiedSolutionMint( - r *http.Request, entry *RouteEntry, -) (solution, publisher string, refused bool) { - if entry == nil || entry.Procedure != workContextMintProcedure { - return "", "", false - } - presented := r.Header.Get(solutionRegistrationHeader) - if presented == "" { - return "", "", false - } - if g.authz == nil { - return "", "", true - } - claims, ok := g.authz.verifySolutionRegistration(r.Context(), presented) - if !ok || !solutionIDPattern.MatchString(claims.Solution) || claims.Subject == "" { - return "", "", true - } - return claims.Solution, claims.Subject, false -} diff --git a/module/services/auth-gateway/code/gateway_solution_credential_test.go b/module/services/auth-gateway/code/gateway_solution_credential_test.go deleted file mode 100644 index b583ec68d..000000000 --- a/module/services/auth-gateway/code/gateway_solution_credential_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// fakeSolutionMint serves accounts' solution-credential exchange with a fixed -// outcome, so the gateway's mapping of each outcome can be asserted alone. -type fakeSolutionMint struct { - code codes.Code - requestCount int -} - -func newSolutionExchangeHarness(t *testing.T) (*Gateway, *fakeSolutionMint) { - t.Helper() - gw, _, _, _ := newGatewayHarness(t) - mint := &fakeSolutionMint{} - - server := grpc.NewServer() - server.RegisterService(&grpc.ServiceDesc{ - ServiceName: "saas.accounts.v1.ModuleCapabilitiesService", - HandlerType: (*any)(nil), - Methods: []grpc.MethodDesc{{ - MethodName: "MintSolutionRegistration", - Handler: func(_ any, _ context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { - req := &accountsv1.SolutionMintRegistrationRequest{} - if err := dec(req); err != nil { - return nil, err - } - mint.requestCount++ - if mint.code != codes.OK { - return nil, status.Error(mint.code, "not issued") - } - return &accountsv1.SolutionMintRegistrationResponse{ - Token: "minted", - ExpiresAt: timestamppb.New(time.Now().Add(5 * time.Minute)), - }, nil - }, - }}, - Metadata: "saas/accounts/v1/module_registration.proto", - }, mint) - - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - go func() { _ = server.Serve(listener) }() - t.Cleanup(server.Stop) - - conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - t.Cleanup(func() { _ = conn.Close() }) - gw.authz.backendConn = conn - return gw, mint -} - -func solutionTokenRequest(id string) *http.Request { - req := httptest.NewRequest(http.MethodPost, "/solutions/"+solutionRegistrationTokenSegment, - strings.NewReader(fmt.Sprintf(`{"id":%q}`, id))) - req.Header.Set(solutionSecretHeader, "example-secret") - req.Header.Set("X-Codefly-Internal-Token", "test-internal-token") - return req -} - -// A registrant acts differently on each outcome of the exchange: a refusal means -// its provisioning is wrong, an unreachable issuer means try again, and an issuer -// that answered with an error means the host is broken. Answering the second -// like either of the others sends an operator after a secret that was correct — -// which is what a restarting accounts looked like before. -func TestGateway_SolutionRegistrationToken_KeepsOutcomesApart(t *testing.T) { - for name, test := range map[string]struct { - code codes.Code - want int - retryAfter bool - }{ - "issued": {codes.OK, http.StatusOK, false}, - "refused": {codes.PermissionDenied, http.StatusUnauthorized, false}, - "issuer unreachable": {codes.Unavailable, http.StatusServiceUnavailable, true}, - "issuer timed out": {codes.DeadlineExceeded, http.StatusServiceUnavailable, true}, - "issuer failed": {codes.Internal, http.StatusBadGateway, false}, - } { - t.Run(name, func(t *testing.T) { - gw, mint := newSolutionExchangeHarness(t) - mint.code = test.code - - w := httptest.NewRecorder() - gw.ServeHTTP(w, solutionTokenRequest("example")) - - require.Equal(t, test.want, w.Code, w.Body.String()) - require.Equal(t, 1, mint.requestCount) - require.Equal(t, test.retryAfter, w.Header().Get("Retry-After") != "") - }) - } -} diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements.go b/module/services/auth-gateway/code/gateway_solution_entitlements.go new file mode 100644 index 000000000..b3f68e705 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -0,0 +1,410 @@ +package main + +// The per-viewer solution entitlement surface (issue #949). +// +// The host's solution projections — the navigation menu and the per-client +// surface listing — used to answer the same deployment-wide set to every caller, +// because what a viewer may USE was nowhere in the request. This endpoint is what +// puts it there. +// +// Why it lives in the gateway, and not in the route handler that needs it: the +// frontend's own /api/* routes are served by Next in-process and are never +// proxied through here, so nothing stamps identity on them and a browser can put +// any x-org-id it likes on one. Verified identity exists at exactly one place in +// this system — behind ext_authz, whose OkResponse headers this file reads. A +// projection that derived the organization itself would be trusting a claim it +// had not verified, and a caller could read another tenant's menu by editing it. +// +// So the division is the same one gateway_solutions.go states for proxied +// solution traffic: the gateway performs authentication and identity projection, +// and accounts remains the authorization authority. This handler authenticates, +// projects the tenant and the viewer, and asks accounts — which answers from the +// installation set and the scope grant union in one transaction. + +import ( + "context" + "errors" + "net/http" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// solutionEntitlementsSegment answers what the CALLING viewer may use, from the +// identity ext_authz verifies on the request. It is the one `/solutions/*` segment +// whose answer differs per caller. +const solutionEntitlementsSegment = "_entitlements" + +// handleSolutionViewerSegment dispatches the `/solutions/*` segments whose answer +// is per viewer. It lives here rather than in the switch in gateway_solutions.go +// so that file keeps naming nothing about installations or entitlements: the +// boundary test in module/tools reads it to assert that the PROXY decision — which +// is still deployment-wide — consults no tenant state, and a dispatch line there +// would defeat a check that is otherwise exact. +func (g *Gateway) handleSolutionViewerSegment(w http.ResponseWriter, r *http.Request, segment string) bool { + if segment != solutionEntitlementsSegment { + return false + } + g.handleSolutionEntitlements(w, r) + return true +} + +const listSolutionEntitlementsMethod = "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" + +// solutionEntitlementRefusalHeader names which of the host's OWN entitlement +// refusals an answer is, so the caller can tell them apart from an ext_authz +// verdict on the user's credential. Status codes alone cannot: a refused internal +// credential and a refused user bearer are both 401, and a caller that read the +// first as the second would tell every signed-in user to re-authenticate over a +// server-side credential fault (and drive a refresh-token rotation on every poll). +// +// It is set by both surfaces that consult the authority — this listing and the +// solution proxy in gateway_solutions.go — because the distinction a caller needs +// is the same on each: a 403 that means "nobody granted your organization this" +// is acted on by installing and granting, and a 403 from ext_authz by signing in. +const solutionEntitlementRefusalHeader = "X-Codefly-Entitlement-Refusal" + +const ( + // refusalInternalCredential: the CALLING SERVICE's cluster-internal token was + // not accepted. A deployment fault, never the user's. + refusalInternalCredential = "internal-credential" + // refusalNoOrganization: the user authenticated, but the session names no + // organization, so there is no org-scoped set to answer. + refusalNoOrganization = "no-organization" + // refusalNotEntitled: the authority answered, and this viewer's organization + // has no installation of the solution the request addressed, or no grant + // reaching this viewer. Set by the proxy, which refuses one named solution + // rather than listing a set. + refusalNotEntitled = "not-entitled" +) + +// errSolutionEntitlementsUnbounded is what a cursor that never terminates +// produces. It is an error rather than a truncated answer because a short list is +// indistinguishable from a smaller entitled set, and a consumer would narrow a +// menu on it. +var errSolutionEntitlementsUnbounded = errors.New("solution entitlement listing did not terminate") + +// solutionEntitlementCallTimeout bounds one authority round trip. A viewer's menu +// blocks on this, so a stalled accounts must surface as a failed projection +// rather than a hung page. +const solutionEntitlementCallTimeout = 10 * time.Second + +// solutionEntitlementPageSize is the page this gateway asks for. It pages until +// the authority stops handing back a cursor: the answer must be the viewer's whole +// entitled set, because a truncated one is indistinguishable to the frontend from +// "your organization installed less than it did". +const solutionEntitlementPageSize = 200 + +// solutionEntitlementPageLimit caps how many pages one request will follow, so a +// cursor that never terminates cannot pin this handler forever. It is far above +// any real organization's installed count; reaching it is a fault, answered as +// one rather than with a silently short list. +const solutionEntitlementPageLimit = 50 + +// solutionEntitlementClient is the authority surface this file consumes. The +// interface exists so the handler can be exercised without an accounts process. +type solutionEntitlementClient interface { + List(ctx context.Context, req *accountsv1.ListSolutionEntitlementsRequest) (*accountsv1.ListSolutionEntitlementsResponse, error) +} + +// accountsSolutionEntitlements invokes the entitlement read by method name over +// the existing accounts connection, presenting the gateway's cluster-internal +// credential — the same shape as accountsSolutionRegistry beside it, and for the +// same reason: there is no vendored client stub for the service, only the shared +// generated message types. +type accountsSolutionEntitlements struct { + conn *grpc.ClientConn + internalToken string +} + +func (c *accountsSolutionEntitlements) List( + ctx context.Context, req *accountsv1.ListSolutionEntitlementsRequest, +) (*accountsv1.ListSolutionEntitlementsResponse, error) { + ctx, cancel := context.WithTimeout(ctx, solutionEntitlementCallTimeout) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", c.internalToken) + resp := &accountsv1.ListSolutionEntitlementsResponse{} + return resp, c.conn.Invoke(ctx, listSolutionEntitlementsMethod, req, resp) +} + +// solutionEntitlementProjection is what a projection consumer reads. It carries +// the organization and viewer this gateway VERIFIED, not the ones anybody asked +// for, so a consumer can key a cache on them without having to trust its own +// request. +type solutionEntitlementProjection struct { + Org string `json:"org"` + Viewer string `json:"viewer"` + Usable []solutionEntitlementEntry `json:"solutions"` +} + +type solutionEntitlementEntry struct { + // The immutable solution TARGET this entitlement is for — the key a consumer + // joins its own registered set against, after resolving whatever alias it + // holds to the target currently serving it. + // + // It replaced a free-text identifier that was the route alias. An alias is + // reusable, so joining on it let a replacement binding inherit the + // predecessor's entitlement; the identity cannot be reused, so it cannot. + TargetID string `json:"targetId"` + // False when the installation is not healthy right now. A consumer shows such + // a solution as unavailable rather than dropping it: the organization did + // install it and the viewer was granted it, so hiding it would send someone + // looking for a grant that exists. What it must not do is route it as healthy. + Healthy bool `json:"healthy"` + // The boundary that admitted it, so a consumer can say WHICH grant made a + // solution visible without a second authority call. + ScopeNodeID string `json:"scopeNodeId"` +} + +// handleSolutionEntitlements answers what the calling viewer may use. +// +// Two credentials, each doing a different job. The cluster-internal token +// establishes that an in-cluster component is asking — the same gate the registry +// snapshot beside it carries. The caller's own bearer is what ext_authz verifies, +// and it is the only thing that decides WHOSE entitlements are answered. +// +// An unauthenticated caller gets 401 from that check, not an empty list. An empty +// projection would make "you are not signed in" and "your organization installed +// nothing" the same answer, and a consumer told the second goes looking at its +// registration instead of its session. +func (g *Gateway) handleSolutionEntitlements(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httpError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if g.authz == nil || !g.authz.acceptsInternalToken(r.Header.Get("X-Codefly-Internal-Token")) { + w.Header().Set(solutionEntitlementRefusalHeader, refusalInternalCredential) + httpError(w, http.StatusUnauthorized, "unauthorized") + return + } + if g.solutionEntitlements == nil { + // No authority client wired. Fail closed and distinguishably: an empty + // projection here would retract every solution a viewer is currently + // using, which is exactly the outage-as-deletion this surface must not do. + httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") + return + } + + // Same identity discipline as every protected route: drop caller-supplied + // identity FIRST, so nothing below can read a header the caller wrote, then + // run ext_authz and stamp what it returns. + stripAllIdentityHeaders(r) + checkResp, err := g.authz.Check(r.Context(), buildCheckRequest(r)) + if err != nil { + httpError(w, http.StatusInternalServerError, "auth check failed") + return + } + if denied := checkResp.GetDeniedResponse(); denied != nil { + code := int(denied.GetStatus().GetCode()) + if code == 0 { + code = http.StatusUnauthorized + } + httpError(w, code, denied.GetBody()) + return + } + if int(checkResp.GetStatus().GetCode()) != 0 { + httpError(w, http.StatusForbidden, "forbidden") + return + } + injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) + + // The verified identity, read from what the check just stamped — never from + // the request as it arrived. + org := r.Header.Get("X-Org-Id") + viewer := effectiveViewer(r) + if org == "" || viewer == "" { + // Authenticated, but carrying no organization: a session that has not + // selected one cannot have an org-scoped entitlement set. That is a + // property of the credential, not an outage, and not an empty tenant's + // menu either — so it is refused rather than answered with []. + w.Header().Set(solutionEntitlementRefusalHeader, refusalNoOrganization) + httpError(w, http.StatusForbidden, "no organization in this session") + return + } + + // Every read here fans into the authority — the grant union plus health for + // each entitled installation — so it draws on the same per-org budget as the + // equivalent catalog read. Without it this would be the one unmetered path + // from a signed-in browser into accounts (the defect #513 closed for the + // solution proxy): the menu polls, and nothing in front of it limits a poll. + // The limiter keys on the X-Org-Id the check just stamped, and fails open on + // a limiter-backend outage exactly as a StandardRead catalog route does. + entry := &RouteEntry{ + Service: "solution:_entitlements", + UpstreamPath: r.URL.Path, + Protected: true, + RateLimitClass: edgeRateLimitClassStandardRead, + } + answer := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + entries, err := g.collectSolutionEntitlements(r.Context(), org, viewer) + if err != nil { + // The authority could not answer. 503, never an empty list: the + // frontend distinguishes the two and holds its last projection rather + // than emptying a working menu. + httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") + return + } + writeSolutionJSON(w, http.StatusOK, solutionEntitlementProjection{ + Org: org, Viewer: viewer, Usable: entries, + }) + }) + if g.rateLimiter == nil { + answer.ServeHTTP(w, r) + return + } + g.rateLimiter.Middleware(limiterFailureModeFor(entry), rateLimitClassFor(entry), + entry.AuthenticationFactorAttempt, answer).ServeHTTP(w, r) +} + +// effectiveViewer is whose entitlements the menu is for: the impersonated user +// while an impersonation is active, otherwise the caller. +// +// ext_authz stamps X-User-Id with the token's `sub`, which during impersonation +// stays the REAL actor, and names the user being viewed in X-Acting-As-User-Id. +// accounts authorizes every request against that effective subject +// (auth.RequestIdentity.EffectiveSubject), so a projection that read X-User-Id +// alone would show an impersonating administrator their own grants inside the +// impersonated user's organization — never the menu that user actually sees, +// which is the one thing impersonation is used to check. +func effectiveViewer(r *http.Request) string { + if acting := r.Header.Get("X-Acting-As-User-Id"); acting != "" { + return acting + } + return r.Header.Get("X-User-Id") +} + +// collectSolutionEntitlements reads every page of the viewer's entitled set. A +// partial answer is not returned as a short list: the caller cannot tell it from +// a genuinely smaller set, and would narrow a menu on it. +func (g *Gateway) collectSolutionEntitlements(ctx context.Context, org, viewer string) ([]solutionEntitlementEntry, error) { + entries := make([]solutionEntitlementEntry, 0) + token := "" + for page := 0; page < solutionEntitlementPageLimit; page++ { + resp, err := g.solutionEntitlements.List(ctx, &accountsv1.ListSolutionEntitlementsRequest{ + OrgId: org, + SubjectId: viewer, + PageSize: solutionEntitlementPageSize, + PageToken: token, + }) + if err != nil { + return nil, err + } + for _, entitlement := range resp.GetEntitlements() { + entries = append(entries, solutionEntitlementEntry{ + TargetID: entitlement.GetTargetId(), + Healthy: entitlement.GetHealthy(), + ScopeNodeID: entitlement.GetRootScopeNodeId(), + }) + } + token = resp.GetNextPageToken() + if token == "" { + return entries, nil + } + } + return nil, errSolutionEntitlementsUnbounded +} + +// viewerSolutionAdmission is what the proxy decision needs from the authority: +// whether this organization installed this solution and this viewer was granted +// it. It is deliberately not a boolean — "not entitled" and "I could not ask" +// are different answers, and a proxy that collapsed them would route an +// uninstalled solution during an accounts outage or refuse an installed one. +type viewerSolutionAdmission int + +const ( + // viewerSolutionAdmitted: the organization installed it and the viewer holds + // a grant that reaches it. + viewerSolutionAdmitted viewerSolutionAdmission = iota + // viewerSolutionNotEntitled: the authority answered, and this solution is + // not in the viewer's entitled set. A verdict, not an outage. + viewerSolutionNotEntitled + // viewerSolutionUndecidable: the authority could not be asked. Never routed + // and never refused as a verdict — the caller answers 503. + viewerSolutionUndecidable +) + +// admitViewerSolution asks the authority whether the solution TARGET the +// request's resolution named is in the viewer's entitled set. +// +// The comparison is between identities, never between aliases. An alias is +// deliberately reusable — a withdrawn one may be claimed by another binding — so +// comparing the alias an installation named against the alias being requested +// admitted a REPLACEMENT binding to its predecessor's installation, forwarded +// the viewer's bearer to it, and exposed it to whoever had been granted the +// predecessor. A target is one continuous period of one binding's presence and +// is never reused, so the replacement resolves to its own target, which no +// installation of the predecessor names. +// +// The target is taken from the routing the CALLER resolved, not re-resolved +// here. That is the whole of it: this function used to call resolveTarget +// itself, so the identity it admitted came from a different read of a mutable +// snapshot than the address its caller then forwarded to, and the two could +// name different bindings. One resolution, passed in, cannot. +// +// A registration with no declaration resolves to no target and is admissible to +// NOBODY. That is deliberate: presence that nothing declared cannot be installed, +// so there is no organisation whose consent could admit it. +// +// It stops at the first match rather than reading every page: finding the id is +// a positive answer on its own, while NOT finding it is only sound after the +// whole set has been walked — so the short-circuit applies to exactly the +// direction where a partial read is conclusive. A cursor that never terminates +// is undecidable, not "not entitled", for the same reason the listing endpoint +// refuses a short answer: a truncated set is indistinguishable from a smaller +// one, and here that difference is whether traffic is refused. +// +// There is no cache, by the same reasoning #949 settled for the listing: the +// answer changes with no write at all — a grant's expires_at passes, a member +// leaves a team, a role loses a permission, an owner is demoted — so a cache +// keyed on any revision a write advances would route on an expired grant. +func (g *Gateway) admitViewerSolution( + ctx context.Context, org, viewer string, routing *solutionRouting, +) viewerSolutionAdmission { + targetID := routing.GetTargetID() + if targetID == "" { + // Declared by nothing: a self-registration no delivery opened a target + // for. There is no identity to be entitled to, so this is a verdict + // rather than an outage — it is not a presence an administrator could + // have consented to, and no later read will make it one. + return viewerSolutionNotEntitled + } + if g.solutionEntitlements == nil { + // No authority client wired. Undecidable, never admitted: a deployment + // that forgot to wire this must fail closed rather than serve every + // solution to every organization, which is the behaviour this check + // replaces. + return viewerSolutionUndecidable + } + token := "" + for page := 0; page < solutionEntitlementPageLimit; page++ { + resp, err := g.solutionEntitlements.List(ctx, &accountsv1.ListSolutionEntitlementsRequest{ + OrgId: org, + SubjectId: viewer, + PageSize: solutionEntitlementPageSize, + PageToken: token, + }) + if err != nil { + return viewerSolutionUndecidable + } + for _, entitlement := range resp.GetEntitlements() { + // Membership of the entitled set is the whole test. Health is + // deliberately NOT read here: an unhealthy installation is still + // installed and still granted, and the registry's own resolve + // already answers 503 for a registration that is not serving — so + // reading Healthy here would turn one condition into two answers + // and make "nobody granted you this" indistinguishable from "it is + // restarting". + if entitlement.GetTargetId() == targetID { + return viewerSolutionAdmitted + } + } + token = resp.GetNextPageToken() + if token == "" { + return viewerSolutionNotEntitled + } + } + return viewerSolutionUndecidable +} diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go new file mode 100644 index 000000000..721df7f27 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -0,0 +1,338 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// fakeSolutionEntitlements stands in for accounts. It records what it was asked, +// because the point of this endpoint is WHICH org and subject reach the authority: +// they must be the ones ext_authz verified, never values the caller supplied. +type fakeSolutionEntitlements struct { + calls []*accountsv1.ListSolutionEntitlementsRequest + // pages is served in order, so the paging loop can be exercised. + pages []*accountsv1.ListSolutionEntitlementsResponse + err error +} + +func (f *fakeSolutionEntitlements) List( + _ context.Context, req *accountsv1.ListSolutionEntitlementsRequest, +) (*accountsv1.ListSolutionEntitlementsResponse, error) { + f.calls = append(f.calls, req) + if f.err != nil { + return nil, f.err + } + if len(f.pages) == 0 { + return &accountsv1.ListSolutionEntitlementsResponse{}, nil + } + page := f.pages[0] + if len(f.pages) > 1 { + f.pages = f.pages[1:] + } + return page, nil +} + +func entitlementGateway(t *testing.T) (*Gateway, *fakeSolutionEntitlements, ed25519.PrivateKey) { + t.Helper() + gw, _, _, priv := newGatewayHarness(t) + authority := &fakeSolutionEntitlements{} + gw.solutionEntitlements = authority + return gw, authority, priv +} + +func entitlementRequest(internalToken, bearer string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/solutions/_entitlements", nil) + if internalToken != "" { + req.Header.Set("X-Codefly-Internal-Token", internalToken) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return req +} + +func decodeEntitlements(t *testing.T, body []byte) solutionEntitlementProjection { + t.Helper() + var out solutionEntitlementProjection + require.NoError(t, json.Unmarshal(body, &out)) + return out +} + +// The whole reason this endpoint exists: a projection cannot derive a verified +// organization, so it asks the component that authenticated. The org and subject +// the authority is asked about must therefore be ext_authz's, and an +// unauthenticated caller must be refused rather than answered. +func TestGatewaySolutionEntitlements_AsksAboutTheVerifiedIdentity(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + token := signValidToken(t, priv) + authority.pages = []*accountsv1.ListSolutionEntitlementsResponse{{ + Entitlements: []*accountsv1.SolutionEntitlement{ + {TargetId: fakeSolutionTarget("audit"), InstallationId: "install-1", RootScopeNodeId: "node-1", Healthy: true}, + }, + }} + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", token)) + + require.Equal(t, http.StatusOK, w.Code) + body := decodeEntitlements(t, w.Body.Bytes()) + require.Len(t, authority.calls, 1) + // Identity comes from the check response, so the answer names the same pair. + require.Equal(t, body.Org, authority.calls[0].GetOrgId()) + require.Equal(t, body.Viewer, authority.calls[0].GetSubjectId()) + require.NotEmpty(t, body.Org) + require.Equal(t, []solutionEntitlementEntry{ + {TargetID: fakeSolutionTarget("audit"), Healthy: true, ScopeNodeID: "node-1"}, + }, body.Usable) +} + +// A forged identity header must never reach the authority. This is the attack the +// endpoint exists to close: the frontend's own routes are not proxied through the +// gateway, so anything they read off a request is caller-controlled. +func TestGatewaySolutionEntitlements_IgnoresCallerSuppliedIdentity(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + req := entitlementRequest("test-internal-token", signValidToken(t, priv)) + req.Header.Set("X-Org-Id", "11111111-1111-1111-1111-111111111111") + req.Header.Set("X-User-Id", "22222222-2222-2222-2222-222222222222") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.NotEqual(t, "11111111-1111-1111-1111-111111111111", authority.calls[0].GetOrgId(), + "the forged organization must have been stripped before ext_authz stamped its own") + require.NotEqual(t, "22222222-2222-2222-2222-222222222222", authority.calls[0].GetSubjectId()) +} + +func TestGatewaySolutionEntitlements_UnauthenticatedIsRefusedNotAnsweredEmpty(t *testing.T) { + gw, authority, _ := entitlementGateway(t) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", "")) + + // 401, never 200 with an empty list: an empty projection is indistinguishable + // from "your organization installed nothing". + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Empty(t, authority.calls, "the authority must not be asked for an unauthenticated caller") +} + +func TestGatewaySolutionEntitlements_RequiresTheInternalCredential(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("", signValidToken(t, priv))) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Empty(t, authority.calls) +} + +func TestGatewaySolutionEntitlements_RejectsNonGET(t *testing.T) { + gw, _, priv := entitlementGateway(t) + req := httptest.NewRequest(http.MethodPost, "/solutions/_entitlements", nil) + req.Header.Set("X-Codefly-Internal-Token", "test-internal-token") + req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) +} + +// An authority that cannot answer must produce 503, never an empty projection: a +// consumer told "[]" retracts every solution the viewer is currently using. +func TestGatewaySolutionEntitlements_AuthorityFailureIs503(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + authority.err = errors.New("accounts unreachable") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signValidToken(t, priv))) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// Same rule for an unwired client: fail closed and distinguishably. +func TestGatewaySolutionEntitlements_NoAuthorityClientIs503(t *testing.T) { + gw, _, priv := entitlementGateway(t) + gw.solutionEntitlements = nil + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signValidToken(t, priv))) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +// The answer must be the viewer's WHOLE entitled set. A truncated one is +// indistinguishable to a consumer from a smaller set, and it would narrow a menu +// on it. +func TestGatewaySolutionEntitlements_FollowsEveryPage(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + authority.pages = []*accountsv1.ListSolutionEntitlementsResponse{ + { + Entitlements: []*accountsv1.SolutionEntitlement{ + {TargetId: fakeSolutionTarget("audit"), RootScopeNodeId: "node-1", Healthy: true}, + }, + NextPageToken: "cursor-1", + }, + { + Entitlements: []*accountsv1.SolutionEntitlement{ + {TargetId: fakeSolutionTarget("ledger"), RootScopeNodeId: "node-2", Healthy: false}, + }, + }, + } + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signValidToken(t, priv))) + + require.Equal(t, http.StatusOK, w.Code) + body := decodeEntitlements(t, w.Body.Bytes()) + require.Equal(t, []solutionEntitlementEntry{ + {TargetID: fakeSolutionTarget("audit"), Healthy: true, ScopeNodeID: "node-1"}, + // Carried, not dropped: an unhealthy installation stays visible and is + // marked unavailable by the consumer. + {TargetID: fakeSolutionTarget("ledger"), Healthy: false, ScopeNodeID: "node-2"}, + }, body.Usable) + require.Len(t, authority.calls, 2) + require.Equal(t, "cursor-1", authority.calls[1].GetPageToken()) +} + +// A cursor that never terminates must not become a silently short answer. +func TestGatewaySolutionEntitlements_NonTerminatingCursorIs503(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + authority.pages = []*accountsv1.ListSolutionEntitlementsResponse{{ + Entitlements: []*accountsv1.SolutionEntitlement{ + {TargetId: fakeSolutionTarget("audit"), RootScopeNodeId: "node-1", Healthy: true}, + }, + NextPageToken: "forever", + }} + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signValidToken(t, priv))) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Len(t, authority.calls, solutionEntitlementPageLimit) +} + +// An empty entitled set is a legitimate 200: the organization installed nothing, +// or nothing was granted to this viewer. It must be distinguishable from every +// failure above, which is why those are 401/403/503. +func TestGatewaySolutionEntitlements_EmptySetIsAnAnswer(t *testing.T) { + gw, _, priv := entitlementGateway(t) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signValidToken(t, priv))) + + require.Equal(t, http.StatusOK, w.Code) + body := decodeEntitlements(t, w.Body.Bytes()) + require.Empty(t, body.Usable) + require.NotEmpty(t, body.Org) +} + +// signImpersonationToken mints the token an impersonation session carries: `sub` +// stays the real actor and `acting` names the user being viewed. +func signImpersonationToken(t *testing.T, priv ed25519.PrivateKey, actor, acting, org string) string { + t.Helper() + return signAccessToken(t, priv, accessKeyID(priv.Public().(ed25519.PublicKey)), accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: actor, + Audience: jwt.ClaimStrings{"saas-starter"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti-impersonation", + }, + OrgID: org, + OrgRole: "member", + SessionID: uuid.Must(uuid.NewV7()).String(), + ActingAsUserID: acting, + }) +} + +// During impersonation the menu is the IMPERSONATED user's. accounts authorizes +// against the effective subject, so asking about the real actor would show an +// administrator their own grants inside someone else's organization — never the +// menu the user they are debugging actually sees. +func TestGatewaySolutionEntitlements_ImpersonationAsksAboutTheImpersonatedUser(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + actor := uuid.Must(uuid.NewV7()).String() + viewed := uuid.Must(uuid.NewV7()).String() + org := uuid.Must(uuid.NewV7()).String() + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signImpersonationToken(t, priv, actor, viewed, org))) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.Equal(t, viewed, authority.calls[0].GetSubjectId(), "the effective subject, not the real actor") + require.Equal(t, org, authority.calls[0].GetOrgId()) + require.Equal(t, viewed, decodeEntitlements(t, w.Body.Bytes()).Viewer) +} + +// A caller cannot put itself into someone else's menu by sending the +// impersonation header: it is stripped before ext_authz stamps its own. +func TestGatewaySolutionEntitlements_IgnoresCallerSuppliedActingHeader(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + req := entitlementRequest("test-internal-token", signValidToken(t, priv)) + req.Header.Set("X-Acting-As-User-Id", "33333333-3333-3333-3333-333333333333") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.NotEqual(t, "33333333-3333-3333-3333-333333333333", authority.calls[0].GetSubjectId()) +} + +// Every read fans into the authority, so it spends the per-org budget like the +// equivalent catalog read. Unmetered, a signed-in browser could drive unbounded +// accounts load through the menu poll. +func TestGatewaySolutionEntitlements_SpendsThePerOrgBudget(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. + gw.rateLimiter = NewRateLimiter(1) + // One token, so every request keys on the same stamped X-Org-Id. + token := signValidToken(t, priv) + + got429 := false + for i := 0; i < 5; i++ { + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", token)) + if w.Code == http.StatusTooManyRequests { + got429 = true + break + } + require.Equal(t, http.StatusOK, w.Code) + } + require.True(t, got429, "the entitlement read must draw on the per-org budget") + require.Less(t, len(authority.calls), 5, "a throttled request must not reach the authority") +} + +// The endpoint's OWN refusals are named, so a caller can tell a fault in its +// cluster credential from a verdict on the user's. Both are 401 on the wire. +func TestGatewaySolutionEntitlements_NamesItsOwnRefusals(t *testing.T) { + gw, _, priv := entitlementGateway(t) + + internal := httptest.NewRecorder() + gw.ServeHTTP(internal, entitlementRequest("wrong-internal-token", signValidToken(t, priv))) + require.Equal(t, http.StatusUnauthorized, internal.Code) + require.Equal(t, "internal-credential", internal.Header().Get("X-Codefly-Entitlement-Refusal")) + + user := httptest.NewRecorder() + gw.ServeHTTP(user, entitlementRequest("test-internal-token", "not-a-token")) + require.Equal(t, http.StatusUnauthorized, user.Code) + require.Empty(t, user.Header().Get("X-Codefly-Entitlement-Refusal"), + "a refused USER credential is ext_authz's verdict, not this endpoint's refusal") +} diff --git a/module/services/auth-gateway/code/gateway_solution_registry.go b/module/services/auth-gateway/code/gateway_solution_registry.go index 667629675..5435e0dab 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry.go +++ b/module/services/auth-gateway/code/gateway_solution_registry.go @@ -1,18 +1,8 @@ package main -// The gateway's client and cache for the durable solution registry (#534). -// -// Registrations used to live in a process-local map here and a second one in -// the frontend. A restart lost them, a registration behind a load balancer -// reached one replica, and the two halves could disagree. The authority is now -// accounts (solution_registrations); this file is the gateway's only view of -// it: a snapshot rebuilt from ListSolutionRegistrations on a timer, on demand -// after a local write, and once on a cache miss. -// -// The gateway is the registry's only client. It owns its own backend half and -// brokers the frontend's half and the frontend's reads, for the same reason it -// brokers module-registration minting: the accounts internal listener admits -// this service account and nothing else. +// Read-only client and replica cache for the durable declared solution registry. +// The gateway refreshes periodically and on cache misses, and brokers the +// projection to the frontend over its existing accounts connection. import ( "context" @@ -26,45 +16,32 @@ import ( accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" "google.golang.org/grpc" - "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" ) const ( - putSolutionRegistrationMethod = "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" - deleteSolutionRegistrationMethod = "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration" - listSolutionRegistrationsMethod = "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" + listSolutionRegistrationsMethod = "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" ) const ( - // solutionRegistryCallTimeout bounds one registry round trip. A registrant - // blocks on this during its startup, so a stalled accounts must surface as - // a failed registration rather than a hung boot. + // solutionRegistryCallTimeout bounds one registry read. solutionRegistryCallTimeout = 10 * time.Second // solutionReconcileInterval is the convergence bound: after a write on any // replica, every other replica reflects it within one interval plus the - // round trip. It is also the cadence at which a replica notices a - // registration whose lease lapsed. + // round trip. solutionReconcileInterval = 10 * time.Second // solutionRefreshFloor rate-limits the on-demand refresh a cache miss // triggers, so a flood of requests for an unregistered id cannot turn into // a flood of registry reads. It is short because it is also the worst-case // convergence delay for a request that arrives at a replica the - // registration has not reached yet. + // declaration has not reached yet. solutionRefreshFloor = 250 * time.Millisecond - // solutionLease is the liveness window a registrant is granted. It is - // comfortably longer than the reconcile interval, so a registrant that - // renews at any sane cadence never flickers out of the snapshot. - solutionLease = 120 * time.Second ) // solutionRegistryClient is the registry surface the gateway consumes. The // interface exists so the cache and the HTTP handlers can be exercised without // an accounts process. type solutionRegistryClient interface { - Put(ctx context.Context, req *accountsv1.PutSolutionRegistrationRequest) (*accountsv1.SolutionRegistration, error) - Delete(ctx context.Context, req *accountsv1.DeleteSolutionRegistrationRequest) (*accountsv1.SolutionRegistration, error) List(ctx context.Context, req *accountsv1.ListSolutionRegistrationsRequest) (*accountsv1.ListSolutionRegistrationsResponse, error) } @@ -85,16 +62,6 @@ func (c *accountsSolutionRegistry) invoke(ctx context.Context, method string, re return c.conn.Invoke(ctx, method, req, resp) } -func (c *accountsSolutionRegistry) Put(ctx context.Context, req *accountsv1.PutSolutionRegistrationRequest) (*accountsv1.SolutionRegistration, error) { - resp := &accountsv1.SolutionRegistration{} - return resp, c.invoke(ctx, putSolutionRegistrationMethod, req, resp) -} - -func (c *accountsSolutionRegistry) Delete(ctx context.Context, req *accountsv1.DeleteSolutionRegistrationRequest) (*accountsv1.SolutionRegistration, error) { - resp := &accountsv1.SolutionRegistration{} - return resp, c.invoke(ctx, deleteSolutionRegistrationMethod, req, resp) -} - func (c *accountsSolutionRegistry) List(ctx context.Context, req *accountsv1.ListSolutionRegistrationsRequest) (*accountsv1.ListSolutionRegistrationsResponse, error) { resp := &accountsv1.ListSolutionRegistrationsResponse{} return resp, c.invoke(ctx, listSolutionRegistrationsMethod, req, resp) @@ -102,7 +69,7 @@ func (c *accountsSolutionRegistry) List(ctx context.Context, req *accountsv1.Lis // solutionResolution is why a solution is or is not routable right now. The // distinctions are the ones an operator needs: never registered, registered but -// not yet whole, registered and gone stale, and "we cannot tell". +// not yet whole, declared and unavailable, and "we cannot tell". type solutionResolution int const ( @@ -110,6 +77,13 @@ const ( solutionUnregistered solutionNotActive solutionRegistryUnavailable + // solutionWrongKind: the alias IS declared, and as the other kind — or with a + // kind this gateway cannot read. Each surface answers it as its own 403. + // + // Distinct from solutionUnregistered because the two are different facts: one + // is an unexposed path, the other a verdict about a record that is here. And + // distinct from solutionNotActive because no deployment arriving changes it. + solutionWrongKind ) // errSolutionRegistryUnconfigured is what a registry call returns when the @@ -121,17 +95,23 @@ var errSolutionRegistryUnconfigured = errors.New("solution registry client not c // solutionRegistryCache is the gateway's replica-local view of the registry. type solutionRegistryCache struct { client solutionRegistryClient - // now is the clock the lease checks read. Tests move it; production leaves + // now is the cache refresh clock. Tests move it; production leaves // it at time.Now. now func() time.Time - mu sync.RWMutex - records map[string]*accountsv1.SolutionRegistration - revision int64 - loaded bool - loadedAt time.Time - lastErr error - refreshing sync.Mutex + mu sync.RWMutex + records map[string]*accountsv1.SolutionRegistration + revision int64 + loaded bool + // attemptedAt is when a read was last ATTEMPTED, successfully or not. The + // refresh floor is measured from it rather than from the last SUCCESS: a + // failed read leaves the snapshot's age where it was, so measuring from that + // made every miss during an outage issue its own full-registry read — the + // flood the floor exists to prevent, arriving exactly when the authority is + // least able to serve it. + attemptedAt time.Time + lastErr error + refreshing sync.Mutex } func newSolutionRegistryCache(client solutionRegistryClient) *solutionRegistryCache { @@ -145,8 +125,7 @@ func newSolutionRegistryCache(client solutionRegistryClient) *solutionRegistryCa // refresh replaces the whole snapshot from authoritative state, tombstones // included. Carrying the tombstone is what lets this replica tell a solution // that was removed from one that never registered: routing treats both as gone, -// but a re-registration has to name the tombstone's revision to be admitted, -// and an operator reading the registry has to be able to see it. +// and the operator projection preserves that distinction. // // A failure leaves the previous snapshot in place: a registry outage must not // empty every replica's routing table. @@ -161,15 +140,30 @@ func (c *solutionRegistryCache) refresh(ctx context.Context) error { // its result, so issuing a fresh registry read for each of them turns a burst // of misses into a serialized queue of full-registry reads, each request // waiting out every one before it. +// +// When the floor suppresses the read it reports the outcome of the most recent +// one rather than nil. The suppressed read is the read the caller was going to +// decide on, and a caller cannot tell "I did not need to ask" from "the last +// answer to that question was an error" — so returning nil handed a burst of +// misses arriving inside the floor a clean bill of health drawn from a read that +// had failed, and the first miss a 503 while the next ones answered 502. func (c *solutionRegistryCache) refreshIfStale(ctx context.Context) error { c.refreshing.Lock() defer c.refreshing.Unlock() if !c.staleEnoughToRefresh() { - return nil + return c.lastLoadErr() } return c.load(ctx) } +// lastLoadErr is the outcome of the most recent registry read: nil once one has +// succeeded, the failure while the authority is unreachable. +func (c *solutionRegistryCache) lastLoadErr() error { + c.mu.RLock() + defer c.mu.RUnlock() + return c.lastErr +} + // load performs the read itself. The caller holds c.refreshing. func (c *solutionRegistryCache) load(ctx context.Context) error { if c.client == nil { @@ -178,6 +172,7 @@ func (c *solutionRegistryCache) load(ctx context.Context) error { resp, err := c.client.List(ctx, &accountsv1.ListSolutionRegistrationsRequest{IncludeTombstoned: true}) c.mu.Lock() defer c.mu.Unlock() + c.attemptedAt = c.now() if err != nil { c.lastErr = err return err @@ -189,7 +184,6 @@ func (c *solutionRegistryCache) load(ctx context.Context) error { c.records = records c.revision = resp.GetRegistryRevision() c.loaded = true - c.loadedAt = c.now() c.lastErr = nil return nil } @@ -226,52 +220,237 @@ func (c *solutionRegistryCache) lookup(id string) (*accountsv1.SolutionRegistrat func (c *solutionRegistryCache) staleEnoughToRefresh() bool { c.mu.RLock() defer c.mu.RUnlock() - return !c.loaded || c.now().Sub(c.loadedAt) >= solutionRefreshFloor + return !c.loaded || c.now().Sub(c.attemptedAt) >= solutionRefreshFloor +} + +// solutionLookup is one read of this replica's view of one alias, with the fact +// a verdict must never be derived without: whether the registry could actually +// be consulted about it. +type solutionLookup struct { + record *accountsv1.SolutionRegistration + found bool + // loaded is false until a snapshot has ever loaded on this replica. + loaded bool + // undecidable is set when this alias had no conclusive record in the + // snapshot AND the on-demand read that would have settled it failed. The + // lookup then rests on absence of evidence, which is not evidence of + // absence. + undecidable bool +} + +// conclusive reports whether a held record settles the question on its own, +// with no registry read. +// +// A live record and a TOMBSTONE both do. The tombstone is the one that is easy +// to get wrong: it is positive, durable evidence that an operator removed the +// registration, carried in every snapshot for exactly that reason — so a failed +// refresh must not convert a deliberate removal into an outage, which would +// answer 503 forever for something that is permanently gone. +// +// The surface is carried in because what counts as "serving, so stop asking" is +// per-surface: a record with a backend half and no frontend is settled for a +// module and NOT settled for a solution, which should still refresh in case the +// frontend half is about to arrive. +func (c *solutionRegistryCache) conclusive( + record *accountsv1.SolutionRegistration, found bool, surface routingSurface, +) bool { + if !found { + return false + } + return record.GetTombstonedAt() != nil || solutionRecordActive(record, surface) } -// resolve answers where to proxy a /solutions//* request, and why not when -// it cannot. A miss triggers at most one on-demand refresh (floor-limited), so -// a solution that registered against another replica a moment ago is reachable +// lookupFresh reads the snapshot, issuing at most one on-demand registry read +// (floor-limited) when what it holds does not settle the question — so a +// solution whose declaration was reconciled by another replica a moment ago is reachable // here without waiting out the reconcile interval. -func (c *solutionRegistryCache) resolve(ctx context.Context, id string) (*url.URL, solutionResolution) { +// +// The read's failure is CARRIED rather than logged and dropped. It used to be +// dropped, and the empty lookup that followed was then reported as a fact: the +// proxy answered "solution not registered" and admission answered "your +// organization did not install this" because accounts was down. An authority +// that cannot be asked must never read as an answer from it. +func (c *solutionRegistryCache) lookupFresh( + ctx context.Context, id string, surface routingSurface, +) solutionLookup { record, found, loaded := c.lookup(id) - if !found || !solutionRecordActive(record, c.now()) { - if err := c.refreshIfStale(ctx); err != nil { - log.Printf("solution registry: on-demand refresh for %q failed: %v", id, err) - } - record, found, loaded = c.lookup(id) + if c.conclusive(record, found, surface) { + return solutionLookup{record: record, found: found, loaded: loaded} + } + refreshErr := c.refreshIfStale(ctx) + if refreshErr != nil { + log.Printf("solution registry: on-demand refresh for %q failed: %v", id, refreshErr) + } + record, found, loaded = c.lookup(id) + return solutionLookup{ + record: record, + found: found, + loaded: loaded, + undecidable: refreshErr != nil && !c.conclusive(record, found, surface), + } +} + +// solutionRouting is ONE resolution of one route alias: the identity admission +// is decided on, and the address traffic is forwarded to, read from the SAME +// record in the SAME lookup. +// +// It is one value rather than two entry points because two reads of mutable +// state are two different answers. The proxy used to take the upstream from +// `resolve` and then, further down the same request, take the target from +// `resolveTarget` — and the snapshot moves between them: a declaration is withdrawn, the +// reconcile loop lands, an on-demand read replaces the record. A replacement +// binding arriving in that window was ADMITTED on its own installation and the +// viewer's bearer was then forwarded to the PREDECESSOR's address, which no +// organisation had consented to reach. Separating the two was argued for as a +// safeguard — "admission needs the identity before any upstream is chosen" — +// and ordering was never the problem: the two reads disagreeing was. +// +// So the resolution is taken once and carried. TargetID is empty for a record +// nothing declared; that is deliberately not an error here, because the caller +// that must refuse it is the one holding the viewer, and it refuses it as a +// verdict rather than as a missing route. +type solutionRouting struct { + // TargetID is the immutable solution target this alias resolves to, empty + // for a registration no declaration opened a target for. + TargetID string + // BindingID is the declared binding this alias resolves to. It is carried on + // the SAME resolution as TargetID and Upstream, for the same reason they are: + // the audience a presented capability is checked against is derived from it + // (`solution:`), and a second read could judge a token against the + // predecessor's audience while traffic went to the replacement's address. + BindingID string + // Upstream is the same record's backend address, normalised to scheme+host. + Upstream *url.URL +} + +// GetTargetID is the resolved target, or empty on a nil routing. A nil routing +// cannot reach admission — the caller's switch on the resolution returns first — +// and answering empty rather than panicking keeps that a refusal if it ever +// does. +func (r *solutionRouting) GetTargetID() string { + if r == nil { + return "" } - if !loaded { + return r.TargetID +} + +// resolveRouting answers where to proxy a /solutions//* request AND which +// solution identity that destination is, or why neither can be answered. A read +// whose snapshot does not settle the question triggers at most one on-demand +// registry read (floor-limited), so a solution whose declaration another +// replica reconciled a moment ago is reachable here without waiting out the reconcile +// interval. +func (c *solutionRegistryCache) resolveRouting(ctx context.Context, id string) (*solutionRouting, solutionResolution) { + return c.resolveRoutingOfKind(ctx, id, solutionSurface) +} + +// resolveModuleRouting is the same resolution for the /v1//* surface. It +// is the same function because the two surfaces must not drift: one lookup, one +// carried resolution, one upstream policy, one tombstone rule, one 120 s +// revocation bound. The only difference is the kind each surface serves. +func (c *solutionRegistryCache) resolveModuleRouting(ctx context.Context, alias string) (*solutionRouting, solutionResolution) { + return c.resolveRoutingOfKind(ctx, alias, moduleSurface) +} + +// routingSurface is which surface is asking. It exists so the kind check is a +// parameter of one resolution rather than a second copy of it. +type routingSurface int + +const ( + solutionSurface routingSurface = iota + moduleSurface +) + +func (c *solutionRegistryCache) resolveRoutingOfKind( + ctx context.Context, id string, surface routingSurface, +) (*solutionRouting, solutionResolution) { + read := c.lookupFresh(ctx, id, surface) + record, found := read.record, read.found + if !read.loaded || read.undecidable { return nil, solutionRegistryUnavailable } - // A tombstoned record is in the snapshot so re-registration and diagnostics - // can see it, but it routes exactly like an id that never registered. + // A tombstoned record is in the snapshot so declaration reconciliation and diagnostics + // can see it, but it routes exactly like an id that never registered. This is + // also how a module stops being routed: carrier deletion is not revocation, + // the applied tombstone is, and it reaches this read within the cache bound. if !found || record.GetTombstonedAt() != nil { return nil, solutionUnregistered } - if !solutionRecordActive(record, c.now()) { + // The kind the record's DECLARATION states, before anything else about it is + // read. A record declared as the other kind is a verdict on this surface, not + // a missing route: the module surface carries no per-viewer admission, so + // serving a solution here would drop its installation check entirely, and + // serving a module on the solution surface would ask for a consent no + // organisation gives a composed module. + // + // UNSPECIFIED fails BOTH checks. It is not a third kind — it is what a reader + // decodes from a writer that did not set the field — so a record carrying it + // is served by neither surface rather than by the one that needs less + // authority. accounts and this gateway ship as one module package at one + // version, which is what makes that refusal a deployment invariant rather + // than an outage waiting for a rolling update. + switch surface { + case moduleSurface: + // A record with NO declaration fails this too, and must: the module + // surface has no admission layer behind it, so the kind check is the only + // place such a record can be refused. + if !declaredModuleKind(record) { + return nil, solutionWrongKind + } + default: + // Only a record that IS declared, and declared as the other kind, is + // refused here. Presence NOTHING declared keeps its existing path: it + // resolves to no target, and per-viewer admission refuses it with the + // entitlement refusal header that tells an operator which of the three + // layers said no. Short-circuiting it here would answer the same 403 while + // losing that, and "nothing declared this" is a different fact from "a + // module is declared here". + if record.GetDeclared() != nil && !declaredSolutionKind(record) { + return nil, solutionWrongKind + } + } + if !solutionRecordActive(record, surface) { return nil, solutionNotActive } upstream, err := url.Parse(record.GetBackend().GetUpstream()) - if err != nil || upstream.Host == "" { - // The upstream was validated before it was stored, so this is a - // corrupted record rather than a caller error. + if err != nil || upstream.Host == "" || + (upstream.Scheme != "http" && upstream.Scheme != "https") || upstream.User != nil || + (upstream.Path != "" && upstream.Path != "/") || upstream.RawQuery != "" || upstream.ForceQuery || upstream.Fragment != "" || + isDisallowedRegisteredUpstreamHost(upstream.Hostname()) { + // The deleted write handlers enforced this URL policy. Enforce it at + // the read boundary before a declared observation becomes a proxy target. log.Printf("solution registry: record %q has an unusable upstream", id) return nil, solutionNotActive } - return &url.URL{Scheme: upstream.Scheme, Host: upstream.Host}, solutionRoutable + return &solutionRouting{ + TargetID: record.GetDeclared().GetTargetId(), + BindingID: record.GetDeclared().GetBindingId(), + Upstream: &url.URL{Scheme: upstream.Scheme, Host: upstream.Host}, + }, solutionRoutable } -// solutionRecordActive re-derives activity here rather than trusting the status -// accounts stamped: the status was computed when the snapshot was read, and a -// lease can lapse while the snapshot is still cached. -func solutionRecordActive(record *accountsv1.SolutionRegistration, now time.Time) bool { - if record == nil || +// solutionRecordActive reads the availability accounts derived from the +// declaration, and checks the halves the SURFACE needs for itself. +// +// Both: the derived status is the authority's answer, and the structural check +// beside it is this gateway refusing to proxy to a half it does not have in hand +// — one read, two reasons, and neither inferred from the other. +// +// The halves differ by kind. A solution serves a page and a backend; a module has +// no browser remote at all, is reached at /v1//*, and nothing loads a +// manifest for it — so requiring a frontend half there would leave every module +// permanently unroutable. accounts derives the same distinction in +// SolutionRegistration.Status, which is why the status check below still holds +// for both. +func solutionRecordActive(record *accountsv1.SolutionRegistration, surface routingSurface) bool { + if record == nil || record.GetTombstonedAt() != nil || record.GetStatus() != accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE { return false } - return record.GetFrontend().GetLeaseExpiresAt().AsTime().After(now) && - record.GetBackend().GetLeaseExpiresAt().AsTime().After(now) + if record.GetBackend() == nil { + return false + } + return surface == moduleSurface || record.GetFrontend() != nil } // snapshot returns the cached records ordered by id, along with the registry @@ -287,82 +466,6 @@ func (c *solutionRegistryCache) snapshot() ([]*accountsv1.SolutionRegistration, return records, c.revision, c.loaded } -// expectedRevisionFor picks the compare-and-swap token for a write, and is the -// single place that rule lives: the handler and the conflict retry must agree, -// or the retry re-derives a token the handler deliberately withheld. -// -// Withholding the token on a tombstone is what refuses a retiring deployment's -// heartbeat — the registry rejects a tombstoned write that names no revision. -// Only a caller that explicitly asked to re-register may name it. -func (c *solutionRegistryCache) expectedRevisionFor(id string, reactivate bool) *int64 { - record, ok, _ := c.lookup(id) - if !ok { - return nil - } - if record.GetTombstonedAt() != nil && !reactivate { - return nil - } - revision := record.GetRevision() - return &revision -} - -// write performs one registration write, refreshing this replica immediately on -// success so the registrant's very next request — which may land here — already -// sees it. -// -// A write that changes an existing half needs the record's current revision. -// This replica's cached revision may be behind, so an Aborted refusal is -// retried once against a freshly read snapshot. A second refusal is returned to -// the caller: two publishers are racing, and picking a winner by looping is -// worse than telling one of them to try again. -// retoken re-derives the compare-and-swap token after a refresh. It is supplied -// by the caller rather than reconstructed here so the initial token and the -// retry token are, structurally, the same rule: a second copy of that rule is -// exactly how a retry once handed a plain heartbeat a tombstone's revision. -func (c *solutionRegistryCache) write( - ctx context.Context, req *accountsv1.PutSolutionRegistrationRequest, retoken func() *int64, -) (*accountsv1.SolutionRegistration, error) { - if c.client == nil { - return nil, errSolutionRegistryUnconfigured - } - record, err := c.client.Put(ctx, req) - // Aborted is the registry saying "your view is behind". That covers a token - // that lost a race and, just as importantly, no token at all because this - // replica had not seen the record yet — the case a refresh actually fixes. - // A tombstone refusal is FailedPrecondition and deliberately does not land - // here, so retrying cannot resurrect a removed registration. - if status.Code(err) == codes.Aborted { - if refreshErr := c.refresh(ctx); refreshErr != nil { - return nil, err - } - req.ExpectedRevision = retoken() - record, err = c.client.Put(ctx, req) - } - if err != nil { - return nil, err - } - if refreshErr := c.refresh(ctx); refreshErr != nil { - log.Printf("solution registry: post-write refresh failed for %q: %v", req.GetSolutionId(), refreshErr) - } - return record, nil -} - -// remove tombstones a registration and refreshes this replica, so the route -// disappears here before the response is written rather than at the next tick. -func (c *solutionRegistryCache) remove(ctx context.Context, id string) (*accountsv1.SolutionRegistration, error) { - if c.client == nil { - return nil, errSolutionRegistryUnconfigured - } - record, err := c.client.Delete(ctx, &accountsv1.DeleteSolutionRegistrationRequest{SolutionId: id}) - if err != nil { - return nil, err - } - if refreshErr := c.refresh(ctx); refreshErr != nil { - log.Printf("solution registry: post-delete refresh failed for %q: %v", id, refreshErr) - } - return record, nil -} - func sortSolutionRecords(records []*accountsv1.SolutionRegistration) { for i := 1; i < len(records); i++ { for j := i; j > 0 && records[j].GetSolutionId() < records[j-1].GetSolutionId(); j-- { @@ -374,18 +477,12 @@ func sortSolutionRecords(records []*accountsv1.SolutionRegistration) { // solutionRegistryStatusLabel renders a record's status for a diagnostic // response. It carries no upstream, manifest, or credential — only the state // name an operator needs to tell these cases apart. -func solutionRegistryStatusLabel(record *accountsv1.SolutionRegistration, now time.Time) string { +func solutionRegistryStatusLabel(record *accountsv1.SolutionRegistration) string { switch record.GetStatus() { case accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE: - if !solutionRecordActive(record, now) { - // The lease lapsed between the snapshot and this read. - return "expired" - } return "active" case accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_PENDING: return "pending" - case accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_EXPIRED: - return "expired" case accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE: return "incompatible" case accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_TOMBSTONED: diff --git a/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go b/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go index 0a9a31e0b..28529428e 100644 --- a/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go +++ b/module/services/auth-gateway/code/gateway_solution_registry_fake_test.go @@ -7,10 +7,9 @@ import ( accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - "google.golang.org/grpc/codes" - grpcstatus "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" + + "google.golang.org/protobuf/proto" ) // fakeSolutionRegistry stands in for accounts in gateway tests. It applies @@ -24,110 +23,120 @@ type fakeSolutionRegistry struct { records map[string]*accountsv1.SolutionRegistration revision int64 - listErr error - putErr error - deleteErr error + listErr error listCalls int - puts []*accountsv1.PutSolutionRegistrationRequest - deletes []string } -// errFakeSolutionNotFound mirrors the code accounts returns for a delete of a -// solution that never registered. -var errFakeSolutionNotFound = grpcstatus.Error(codes.NotFound, "solution registration not found") - func newFakeSolutionRegistry() *fakeSolutionRegistry { return &fakeSolutionRegistry{records: map[string]*accountsv1.SolutionRegistration{}} } -func (f *fakeSolutionRegistry) Put( - _ context.Context, req *accountsv1.PutSolutionRegistrationRequest, -) (*accountsv1.SolutionRegistration, error) { +// fakeSolutionTarget is the target id the fake derives for an alias's FIRST +// declaration. It is a function of the alias so a test and the fake agree without +// passing ids around; declareTarget below is how a test says "a different +// binding now serves this alias", which no derivation can express. +func fakeSolutionTarget(alias string) string { return "target-" + alias } + +// seedDeclared creates a fully declared record, which is how a test gets one +// now that the runtime registration writer is deleted. +// +// Record CREATION in this fake used to come only from the writer's helpers +// (registerSolutionUpstream / registerSolutionHalves), so with those gone there +// was no way to put a record in front of the handlers at all — declareTarget +// below only REPLACES the declaration on a record that already exists. That gap +// is what item 4 left behind in the test infrastructure, and this closes it on +// the declared side: delivery is what brings a record into being, so the fake +// needs a way to say so without pretending a runtime registered itself. +func (f *fakeSolutionRegistry) seedDeclared(alias, upstream string) { f.mu.Lock() defer f.mu.Unlock() - f.puts = append(f.puts, proto.Clone(req).(*accountsv1.PutSolutionRegistrationRequest)) - if f.putErr != nil { - return nil, f.putErr - } - record := f.records[req.GetSolutionId()] - // Mirror the registry's compare-and-swap and tombstone refusals. A fake that - // accepts every write makes the gateway's own tombstone handling untestable: - // it would resurrect a removed registration and the test asserting that - // cannot happen would still pass. - if record != nil { - if req.ExpectedRevision != nil && req.GetExpectedRevision() != record.GetRevision() { - return nil, grpcstatus.Error(codes.Aborted, "solution registration revision is stale") - } - if record.GetTombstonedAt() != nil && req.ExpectedRevision == nil { - return nil, grpcstatus.Error(codes.FailedPrecondition, "solution registration is tombstoned") - } - if record.GetTombstonedAt() == nil && req.ExpectedRevision == nil && - fakeSolutionHalfDiffers(record, req) { - return nil, grpcstatus.Error(codes.Aborted, "solution registration revision required") - } - } f.revision++ - if record == nil { - record = &accountsv1.SolutionRegistration{ - SolutionId: req.GetSolutionId(), - Publisher: req.GetPublisher(), - // No RuntimeBoundary: accounts never puts the seed on any response - // (issue #1015), and the accounts test that holds it to that is - // TestSolutionRegistrationResponsesCarryNoRuntimeBoundary. A fake - // that sent one here would let the gateway grow a reader for a field - // it will never receive. - } - f.records[req.GetSolutionId()] = record + f.records[alias] = &accountsv1.SolutionRegistration{ + SolutionId: alias, + Publisher: "solution:" + alias, + Revision: f.revision, + Declared: &accountsv1.SolutionDeclaredBinding{ + BindingId: "acme.test." + alias, + Generation: 1, + Release: "acme/" + alias + "@1.0.0", + TargetId: fakeSolutionTarget(alias), + // Every declaration states a kind: core requires it and refuses any + // other value, so a fake record without one models a state delivery + // cannot produce — and would quietly stop being routed at all. + Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION, + }, + Frontend: &accountsv1.SolutionFrontendBinding{ + Revision: f.revision, Manifest: `{"id":"` + alias + `"}`, ContractVersion: "v1", + }, + Backend: &accountsv1.SolutionBackendBinding{ + Revision: f.revision, Upstream: upstream, + ServiceAlias: alias, ContractVersion: "v1", + }, } - if record.GetTombstonedAt() != nil { - // An admitted reactivation starts from a clean record, as accounts does. - record.Frontend, record.Backend = nil, nil +} + +// declareTarget replaces the declaration on an existing record, so a test can +// model a REPLACEMENT presence: the same route alias, served by a different +// binding, under a target that is not the one any earlier installation named. +func (f *fakeSolutionRegistry) declareTarget(alias, bindingID, targetID string) { + f.mu.Lock() + defer f.mu.Unlock() + record := f.records[alias] + if record == nil { + return } - record.TombstonedAt = nil + f.revision++ record.Revision = f.revision - lease := timestamppb.New(time.Now().Add(time.Duration(req.GetLeaseSeconds()) * time.Second)) - if half := req.GetFrontend(); half != nil { - record.Frontend = &accountsv1.SolutionFrontendBinding{ - Revision: f.revision, - Manifest: half.GetManifest(), - ContractVersion: half.GetContractVersion(), - LeaseExpiresAt: lease, - } - } - if half := req.GetBackend(); half != nil { - record.Backend = &accountsv1.SolutionBackendBinding{ - Revision: f.revision, - Upstream: half.GetUpstream(), - ServiceAlias: half.GetServiceAlias(), - ContractVersion: half.GetContractVersion(), - LeaseExpiresAt: lease, - } + record.Declared = &accountsv1.SolutionDeclaredBinding{ + BindingId: bindingID, + Generation: 1, + Release: "acme/" + alias + "@2.0.0", + TargetId: targetID, + Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION, } - record.Status = fakeSolutionStatus(record, time.Now()) - return proto.Clone(record).(*accountsv1.SolutionRegistration), nil } -func (f *fakeSolutionRegistry) Delete( - _ context.Context, req *accountsv1.DeleteSolutionRegistrationRequest, -) (*accountsv1.SolutionRegistration, error) { +// tombstone applies a withdrawal to a record, which is how a declared presence +// stops being served: the row stays in the snapshot so diagnostics can see it, +// and routing treats it exactly like an alias nothing ever declared. +func (f *fakeSolutionRegistry) tombstone(alias string) { f.mu.Lock() defer f.mu.Unlock() - f.deletes = append(f.deletes, req.GetSolutionId()) - if f.deleteErr != nil { - return nil, f.deleteErr - } - record := f.records[req.GetSolutionId()] + record := f.records[alias] if record == nil { - return nil, errFakeSolutionNotFound + return } f.revision++ record.Revision = f.revision - record.Frontend = nil - record.Backend = nil record.TombstonedAt = timestamppb.Now() - record.Status = accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_TOMBSTONED - return proto.Clone(record).(*accountsv1.SolutionRegistration), nil +} + +// repointUpstream changes a declared record's observed endpoint to model a +// concurrent delivery observation while a request is being admitted. +func (f *fakeSolutionRegistry) repointUpstream(alias, upstream string) { + f.mu.Lock() + defer f.mu.Unlock() + record := f.records[alias] + if record == nil || record.GetBackend() == nil { + return + } + f.revision++ + record.Revision = f.revision + record.Backend.Revision = f.revision + record.Backend.Upstream = upstream +} + +// undeclare strips a record's declaration, modelling presence nothing declared. +// Such a record resolves to no target and is admissible to nobody. +func (f *fakeSolutionRegistry) undeclare(alias string) { + f.mu.Lock() + defer f.mu.Unlock() + if record := f.records[alias]; record != nil { + f.revision++ + record.Revision = f.revision + record.Declared = nil + } } func (f *fakeSolutionRegistry) List( @@ -156,8 +165,20 @@ func (f *fakeSolutionRegistry) List( // pending, so a half that stopped renewing reads as dead rather than waiting. // A fake that ranks these differently hands tests a status the real registry // would never produce. +// +// The module arm mirrors it too: a module has no browser remote, so a frontend +// half is not something it is waiting for. A fake that left modules PENDING would +// make every module-routing test fail for a reason the real registry does not +// have. func fakeSolutionStatus(record *accountsv1.SolutionRegistration, now time.Time) accountsv1.SolutionRegistrationStatus { front, backend := record.GetFrontend(), record.GetBackend() + if record.GetTombstonedAt() == nil && + record.GetDeclared().GetKind() == accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE { + if backend == nil { + return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_PENDING + } + return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE + } switch { case record.GetTombstonedAt() != nil: return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_TOMBSTONED @@ -165,33 +186,9 @@ func fakeSolutionStatus(record *accountsv1.SolutionRegistration, now time.Time) front.GetContractVersion() != "" && backend.GetContractVersion() != "" && front.GetContractVersion() != backend.GetContractVersion(): return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE - case front != nil && !front.GetLeaseExpiresAt().AsTime().After(now): - return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_EXPIRED - case backend != nil && !backend.GetLeaseExpiresAt().AsTime().After(now): - return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_EXPIRED case front == nil || backend == nil: return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_PENDING default: return accountsv1.SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE } } - -// fakeSolutionHalfDiffers reports whether a write replaces a half the record -// already holds with different content — the case the registry refuses unless -// the caller names the revision it believes it is replacing. -func fakeSolutionHalfDiffers( - record *accountsv1.SolutionRegistration, req *accountsv1.PutSolutionRegistrationRequest, -) bool { - if half := req.GetFrontend(); half != nil { - held := record.GetFrontend() - return held != nil && (held.GetManifest() != half.GetManifest() || - held.GetContractVersion() != half.GetContractVersion()) - } - if half := req.GetBackend(); half != nil { - held := record.GetBackend() - return held != nil && (held.GetUpstream() != half.GetUpstream() || - held.GetServiceAlias() != half.GetServiceAlias() || - held.GetContractVersion() != half.GetContractVersion()) - } - return false -} diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index c6b2e2987..4ff6d36d9 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -2,55 +2,52 @@ package main import ( "encoding/json" - "errors" "net" "net/http" - "net/url" stdpath "path" "regexp" "strings" - accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - - "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" - grpcstatus "google.golang.org/grpc/status" ) -// The runtime solution surface: /solutions/* proxying, plus the internal -// registration endpoints both halves of a solution register through. -// -// An independently deployed component the saas has no build-time knowledge of -// POSTs its upstream on startup and the gateway then proxies matching requests -// to it. This is a deliberate, contained relaxation of the otherwise -// static-catalog-only rule (every request must match an explicit catalog -// entry): proxied data endpoints stay auth-required, the same ext_authz Check -// runs, and identity headers are stripped and re-stamped exactly as for catalog -// routes. The gateway performs authentication and identity projection; the -// registered upstream's own downstream calls remain the authorization -// authority. -// -// Registrations are durable and shared (see gateway_solution_registry.go): they -// live in accounts, not in this process, so they survive a restart and every -// replica converges on the same revision. What this file owns is the perimeter -// — who may register, what an upstream may point at — and the proxy itself. +// Solution routing reads the durable declared registry. Every authenticated +// request uses the ordinary identity pipeline and per-viewer admission. const solutionPrefix = "/solutions/" const ( - // solutionRegisterPath registers (POST) or deregisters (DELETE) a - // solution's backend half — the upstream this gateway proxies to. - solutionRegisterSegment = "_register" - // solutionFrontendSegment registers the frontend half. The frontend has no - // route to the accounts internal listener, so it hands its validated - // manifest to the gateway, which is already the broker for that listener. - solutionFrontendSegment = "_frontend" - // solutionRegistrySegment reads this replica's snapshot: the frontend - // rebuilds its own cache from it, and an operator reads it to tell an - // unregistered solution from a pending, expired, or removed one. + solutionServicePrefix = "solution:" + // solutionRegistrySegment serves the internal read-only snapshot. solutionRegistrySegment = "_registry" ) +// solutionRouteAudience is the audience a capability presented to a solution route +// must name: the host's `solution:` vocabulary entry for the binding +// that route resolved to. +// +// The BINDING, not the alias. An alias is deliberately reusable, so an audience +// keyed on it would let a replacement binding under the same route be addressed as +// its predecessor — the same reuse hole the artifact-approval authority and the +// boundary derivation both had to close. +// +// An empty binding id yields an empty expectation, which means "do not compare". +// That case is unreachable from here: a record with no declaration is refused +// before this, and a routable resolution always carries a binding. It is written +// this way rather than defended with a panic because the surrounding refusals are +// the real guarantee, and a nil-safe accessor beside them is not a second one. +func solutionRouteAudience(bindingID string) string { + if bindingID == "" { + return "" + } + return solutionAudiencePrefix + bindingID +} + +// solutionAudiencePrefix mirrors business.SolutionAudiencePrefix in accounts. The +// gateway cannot import it — separate Go modules — so the two are pinned to each +// other by a test rather than left to agree by eye. +const solutionAudiencePrefix = "solution:" + // solutionIDPattern mirrors the identity rule the registry validates: one // lowercase segment usable as a path element and a routing key. var solutionIDPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$`) @@ -64,43 +61,55 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) } rest := strings.TrimPrefix(r.URL.Path, solutionPrefix) - // Registration and registry reads mutate or expose the proxy's routing state - // and decide what the host loads as an in-origin remote, so they are - // authenticated with the registrant's own signed, solution-bound credential - // rather than with network placement or a secret the whole mesh shares. switch rest { - case solutionRegistrationTokenSegment: - g.handleSolutionRegistrationToken(w, r) - return true - case solutionRegisterSegment: - g.handleSolutionRegister(w, r) - return true - case solutionFrontendSegment: - g.handleSolutionFrontendRegister(w, r) - return true case solutionRegistrySegment: g.handleSolutionRegistrySnapshot(w, r) return true } + // The per-viewer control segments dispatch from their own file, so that what + // remains BELOW in this one is the proxy decision alone — registered, active, + // authenticated, routed — with no reference to any tenant's installations. That + // separation is load-bearing: route and page exposure stays deployment-wide + // while the projections do not, and module/tools' boundary test reads this file + // to hold the first half of that (SOLUTION_REGISTRATION.md §4). + if g.handleSolutionViewerSegment(w, r, rest) { + return true + } + id, path, _ := strings.Cut(rest, "/") - if id == "" { + if !solutionIDPattern.MatchString(id) { httpError(w, http.StatusNotFound, "solution not specified") return true } - upstream, resolution := g.solutions.resolve(r.Context(), id) + // ONE resolution, carried from here to the forward. Admission is decided on + // routing.TargetID and traffic goes to routing.Upstream, and both are read + // from the same record in the same lookup — see solutionRouting. A second + // read further down this handler is what let a replacement binding be + // admitted on its own installation while the bearer went to its + // predecessor's address. + routing, resolution := g.solutions.resolveRouting(r.Context(), id) switch resolution { case solutionUnregistered: httpError(w, http.StatusBadGateway, "solution not registered") return true case solutionNotActive: - // Registered but not serving: a half never arrived, a lease lapsed, the - // halves disagree on a contract version, or it was deregistered. This is + // Registered but not serving: a half is absent, the + // halves disagree on a contract version, or delivery withdrew it. This is // deliberately not the "not registered" answer — the distinction is what // tells an operator whether to look for a missing deployment or a // misbehaving one. httpError(w, http.StatusServiceUnavailable, "solution registration not active") return true + case solutionWrongKind: + // The alias is declared, and not as a SOLUTION — a module, or a record + // whose kind this gateway cannot read. Same answer and same code as a + // record nothing declared, for the same reason: from this surface it is + // not a solution this host declares, and no later read changes that. A + // module is reached at /v1//*, which is where its own refusal is + // worded. + httpError(w, http.StatusForbidden, "solution is not declared on this host") + return true case solutionRegistryUnavailable: // No snapshot has ever loaded, so this replica cannot tell an // unregistered solution from a registered one. Fail closed and say so. @@ -108,6 +117,28 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) return true } + // THE ROUTE IS RESOLVED, SO THE AUDIENCE IS KNOWN: a capability presented to + // this route must name THIS binding. Derived from the same carried resolution + // that chose the upstream, so the token cannot be judged against one binding + // while traffic goes to another's address. + // + // This is what makes A-for-B a 403-with-the-audience-error rather than a + // forward: a capability minted for `solution:` verified cleanly + // here before, and only the callee could notice. + // + // IT IS NOT AN AUTHORITY CHECK, and the per-viewer installation admission below + // stays load-bearing. The mint holds a requested audience to the host's closed + // vocabulary, which makes an audience PLAUSIBLE — but it ties it to the caller + // only when that caller carries an audience ceiling, and accounts' + // enforceActorAudience treats an empty ceiling as unrestricted while the actor + // is nil for every owner-only mint. So a viewer can hold a capability naming a + // binding it was never installed against, and what refuses the request is the + // admission call, not this comparison. Deleting that admission because "the + // audience already covers it" would remove the only check that does. + if g.rejectInvalidWorkContext(w, r, solutionRouteAudience(routing.BindingID)) { + return true + } + // A solution's static Module-Federation surface — the MF manifest, remote // entry, and JS chunks under /assets, plus the public /.well-known documents — // is fetched by the browser's module loader with no bearer, exactly as the @@ -130,13 +161,29 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) // unauthenticated flood of this proxy is still capped. Leaving it on bare // proxyTo would make it the one unmetered public proxy in the gateway (#513). if publicPath, ok := solutionPublicUpstreamPath(r.Method, path); ok { + if routing.TargetID == "" { + // Presence nothing declared. This fetch carries no credential, so + // there is no VIEWER to ask the authority about — but there is still + // an installation question with a final answer, and it needs no + // viewer: a registration no declaration opened a target for cannot + // have been installed by any organisation, so nobody consented to + // this host serving its bytes. Until this check, such a record's + // remote entry and chunks were served same-origin and executed + // inside the host origin under its own `'self'`, while the + // authenticated surface beside it refused the same record outright. + // + // A verdict (403), not an outage: the record is here, it is active, + // and it is declared by nothing — no later read changes that. + httpError(w, http.StatusForbidden, "solution is not declared on this host") + return true + } stripAllIdentityHeaders(r) entry := &RouteEntry{ Service: "solution:" + id, UpstreamPath: publicPath, RateLimitClass: edgeRateLimitClassPublic, } - g.rateLimitThenProxy(w, r, upstream, entry) + g.rateLimitThenProxy(w, r, routing.Upstream, entry) return true } @@ -184,6 +231,40 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) } injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) + // AVAILABLE is not INSTALLED. Delivery (or, until the cutover, a + // registration) makes a solution available on this deployment; an + // organization installing it and a grant reaching the viewer are what make + // it usable. Those are three layers and none is inferred from another, so + // the proxy asks the authority before it forwards anything — including the + // caller's own bearer, which is the part that matters: without this check a + // viewer in any organization reached the data endpoints of every solution + // the deployment runs, and the solution received a real bearer for them. + // + // It is deliberately AFTER ext_authz. The identity this is decided on must + // be the verified one, and during an impersonation it must be the + // impersonated viewer — the subject accounts authorizes against — rather + // than the administrator acting. Both come from what the check just stamped. + // + // The public GET surface above is not gated PER VIEWER and cannot be: it is + // fetched by the browser's module loader with no credential, so there is no + // viewer to ask about. What it serves is the solution's own static + // Module-Federation bytes, which carry no tenant data; the authenticated + // surface below is where an organization's admission is enforced. + // + // It is not declaration-blind, though, and the two were conflated for as + // long as "there is no viewer" was treated as "nothing can be checked". A + // record nothing declared is refused above, because that answer needs no + // viewer. + org := r.Header.Get("X-Org-Id") + viewer := effectiveViewer(r) + if org == "" || viewer == "" { + // Authenticated, but the session names no organization. There is no + // org-scoped admission to read, so this is refused rather than routed — + // the same answer, for the same reason, as the entitlement listing. + w.Header().Set(solutionEntitlementRefusalHeader, refusalNoOrganization) + httpError(w, http.StatusForbidden, "no organization in this session") + return true + } // Proxy to the solution. The caller's bearer is preserved so the solution // can call accounts through the gateway on the user's behalf. // @@ -212,7 +293,37 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) Protected: true, RateLimitClass: class, } - g.rateLimitThenProxy(w, r, upstream, entry) + // Metered FIRST, and the admission check runs inside the budget. + // + // The authority call is the expensive part of this path — up to one + // scope-tree read per entitlement page, per request — and it costs the same + // whether the request is forwarded, refused as not entitled, or abandoned + // because accounts is down. Admitting before metering therefore left an + // authenticated caller an unmetered way to spend that cost: ask about a + // solution it is not entitled to, as fast as it likes, and every refusal was + // free. The entitlement LISTING already wrapped its work this way; the proxy + // did not. + g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch g.admitViewerSolution(r.Context(), org, viewer, routing) { + case viewerSolutionNotEntitled: + // A verdict on this organization's admission, named so a client can + // tell it from an ext_authz denial on the credential: one is fixed by + // installing and granting, the other by signing in again. + w.Header().Set(solutionEntitlementRefusalHeader, refusalNotEntitled) + httpError(w, http.StatusForbidden, "solution is not installed for this organization") + return + case viewerSolutionUndecidable: + // The authority could not be asked. 503 and nothing forwarded: + // routing would serve a solution nobody has confirmed is installed, + // and 403 would tell an operator the grant is missing when accounts + // is simply down. + httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") + return + } + // The destination is the one this request's single resolution named, and + // the one whose identity the authority just admitted. + g.proxyTo(w, r, routing.Upstream, entry) + })) return true } @@ -245,262 +356,30 @@ func solutionPublicUpstreamPath(method, subPath string) (string, bool) { return "", false } -// solutionRegistrationBody is the shared shape of both registration endpoints. -// Only one of the halves' fields is read by each handler; sharing the envelope -// keeps identity, ownership, and the compare-and-swap controls identical on -// both, which is the point of having one record behind them. -type solutionRegistrationBody struct { - ID string `json:"id"` - Publisher string `json:"publisher"` - // ExpectedRevision lets a registrant that tracks its own revision drive the - // compare-and-swap itself. Left unset, the gateway supplies the revision it - // last saw for the record, which is what makes the pre-existing - // `{id, upstream}` registration call keep working unchanged. - ExpectedRevision *int64 `json:"expectedRevision"` - // Reactivate is the explicit re-registration of a deregistered solution. A - // plain retry from a retiring deployment does not set it, so it cannot - // resurrect what an operator removed. - Reactivate bool `json:"reactivate"` - ContractVersion string `json:"contractVersion"` - - // Backend half. - Upstream string `json:"upstream"` - ServiceAlias string `json:"serviceAlias"` - - // Frontend half. The manifest is carried as the exact JSON text the - // frontend validated, not as a nested object: the gateway stores it - // verbatim and never reinterprets it, and byte stability is what lets the - // registry recognise a re-registration as a lease renewal rather than a - // change. - Manifest string `json:"manifest"` -} - -// decodeSolutionRegistration reads and validates the parts of a registration -// body common to both halves. It writes the error response itself and returns -// ok=false when the caller must stop. -func (g *Gateway) decodeSolutionRegistration(w http.ResponseWriter, r *http.Request) (*solutionRegistrationBody, bool) { - if r.Method != http.MethodPost { - httpError(w, http.StatusMethodNotAllowed, "method not allowed") - return nil, false - } - // Registration is a privileged mutation, not a public endpoint: it decides - // where authenticated solution traffic (bearer + injected identity) gets - // forwarded and what the host renders as an in-origin remote. Require the - // cluster-internal token — the same credential the frontend presents to - // establish a trusted origin — so an unauthenticated edge caller cannot - // register an attacker-controlled upstream and harvest forwarded bearers. - // acceptsInternalToken fails closed on an empty/unset credential. - claims, authorized := g.acceptsSolutionRegistrationCredential(r) - if !authorized { - httpError(w, http.StatusUnauthorized, "unauthorized") - return nil, false - } - body := &solutionRegistrationBody{} - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxSolutionRegistrationBytes)).Decode(body); err != nil { - httpError(w, http.StatusBadRequest, "invalid json") - return nil, false - } - if !solutionIDPattern.MatchString(body.ID) { - httpError(w, http.StatusBadRequest, "missing id") - return nil, false - } - // The credential names the one id it may act on, so a holder of Example - // Solution A's credential cannot write B's record whatever the body says. - if claims.Solution != body.ID { - httpError(w, http.StatusForbidden, "solution not authorized for this identity") - return nil, false - } - // The publisher is the VERIFIED subject, never a caller-supplied field: that - // is what turns the registry's cross-publisher refusal from a first-claim - // convention into an owner-bound guarantee. - body.Publisher = claims.Subject - return body, true -} - -// maxSolutionRegistrationBytes bounds a registration body. A frontend manifest -// carries a nav entry, a remote descriptor, and an optional dashboard graph; -// 256 KiB is far above any of those and matches what the registry accepts. -const maxSolutionRegistrationBytes = 256 << 10 - -// acceptsSolutionRegistrationCredential verifies the presented registration -// credential and burns its single use, returning the claims it proved. The -// shared cluster-internal token is deliberately NOT accepted: it attests to no -// particular publisher, which is what left the registry's publisher binding -// first-claim-wins on the id rather than owner-bound. -func (g *Gateway) acceptsSolutionRegistrationCredential( - r *http.Request, -) (*solutionRegistrationClaims, bool) { - claims, ok := g.authz.verifySolutionRegistration( - r.Context(), r.Header.Get(solutionRegistrationHeader)) - if !ok { - return nil, false - } - // A credential is fetched per attempt and presented once. Burning the jti - // stops a copy captured in transit or in a log from re-pointing a route - // inside its remaining lifetime. - if !g.registrationReplay.consume(claims.ID, claims.ExpiresAt.Time) { - return nil, false - } - return claims, true -} - -// handleSolutionRegister serves the backend half: POST registers or renews the -// upstream, DELETE deregisters the whole solution. -func (g *Gateway) handleSolutionRegister(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete { - g.handleSolutionDeregister(w, r) - return - } - body, ok := g.decodeSolutionRegistration(w, r) - if !ok { - return - } - upstream, err := url.Parse(body.Upstream) - if err != nil || (upstream.Scheme != "http" && upstream.Scheme != "https") || upstream.Host == "" { - httpError(w, http.StatusBadRequest, "invalid upstream") - return - } - // Defence in depth against a confused or compromised internal caller: never - // let a solution upstream point at the cloud metadata endpoint or another - // link-local/unspecified address (the credential-theft SSRF sinks reachable - // from inside the mesh). Loopback is deliberately allowed — local - // `codefly run` solutions self-register loopback upstreams, and a deployed - // upstream is a cluster DNS name, never link-local. - if isDisallowedRegisteredUpstreamHost(upstream.Hostname()) { - httpError(w, http.StatusBadRequest, "forbidden upstream host") - return - } - alias := body.ServiceAlias - if alias == "" { - alias = body.ID - } - req := g.newSolutionPut(body) - req.Half = &accountsv1.PutSolutionRegistrationRequest_Backend{ - Backend: &accountsv1.SolutionBackendRegistration{ - // Normalized to scheme+host: the registry stores the routing target, - // not whatever path or query the registrant happened to send. - Upstream: (&url.URL{Scheme: upstream.Scheme, Host: upstream.Host}).String(), - ServiceAlias: alias, - ContractVersion: body.ContractVersion, - }, - } - g.submitSolutionPut(w, r, req, body) -} - -// handleSolutionFrontendRegister serves the frontend half. The frontend cannot -// reach the accounts internal listener itself — the mesh policy admits only -// this gateway's service account — so it registers through here, exactly as a -// composed module obtains its registration credential through here. -func (g *Gateway) handleSolutionFrontendRegister(w http.ResponseWriter, r *http.Request) { - body, ok := g.decodeSolutionRegistration(w, r) - if !ok { - return - } - // The manifest's contents are the frontend's business; the gateway checks - // only that it was given a document, so a truncated or empty body fails - // here rather than being stored as a valid-looking half. - if !json.Valid([]byte(body.Manifest)) { - httpError(w, http.StatusBadRequest, "invalid manifest") - return - } - req := g.newSolutionPut(body) - req.Half = &accountsv1.PutSolutionRegistrationRequest_Frontend{ - Frontend: &accountsv1.SolutionFrontendRegistration{ - Manifest: body.Manifest, - ContractVersion: body.ContractVersion, - }, - } - g.submitSolutionPut(w, r, req, body) -} - -// newSolutionPut builds the part of a registry write both halves share, -// including the compare-and-swap token when the caller did not supply one. The -// caller fills in Half. -func (g *Gateway) newSolutionPut(body *solutionRegistrationBody) *accountsv1.PutSolutionRegistrationRequest { - return &accountsv1.PutSolutionRegistrationRequest{ - SolutionId: body.ID, - Publisher: body.Publisher, - LeaseSeconds: uint32(solutionLease.Seconds()), - ExpectedRevision: g.solutionExpectedRevision(body), - } -} - -// submitSolutionPut sends the write and renders its outcome. -func (g *Gateway) submitSolutionPut( - w http.ResponseWriter, r *http.Request, req *accountsv1.PutSolutionRegistrationRequest, - body *solutionRegistrationBody, -) { - record, err := g.solutions.write(r.Context(), req, func() *int64 { - return g.solutionExpectedRevision(body) - }) - if err != nil { - writeSolutionRegistryError(w, err) - return - } - g.writeSolutionRegistrationResult(w, record) -} - -// solutionExpectedRevision picks the compare-and-swap token for a write. -// -// An explicit token from the registrant always wins. Otherwise the gateway -// supplies the revision it last saw, which is what turns the unchanged -// `{id, upstream}` call into a correct read-modify-write. The one case it -// deliberately withholds a token is a tombstoned record without `reactivate`: -// the registry then refuses the write, which is exactly how a delayed retry -// from a retired deployment is stopped from recreating a removed registration. -func (g *Gateway) solutionExpectedRevision(body *solutionRegistrationBody) *int64 { - if body.ExpectedRevision != nil { - return body.ExpectedRevision - } - return g.solutions.expectedRevisionFor(body.ID, body.Reactivate) -} - -// handleSolutionDeregister tombstones a registration. Both halves go away -// together: a solution is removed as a unit, so there is no window where the -// page survives its backend or the other way round. -func (g *Gateway) handleSolutionDeregister(w http.ResponseWriter, r *http.Request) { - claims, authorized := g.acceptsSolutionRegistrationCredential(r) - if !authorized { - httpError(w, http.StatusUnauthorized, "unauthorized") - return - } - id := r.URL.Query().Get("id") - if !solutionIDPattern.MatchString(id) { - httpError(w, http.StatusBadRequest, "missing id") - return - } - if claims.Solution != id { - httpError(w, http.StatusForbidden, "solution not authorized for this identity") - return - } - record, err := g.solutions.remove(r.Context(), id) - if err != nil { - writeSolutionRegistryError(w, err) - return - } - g.writeSolutionRegistrationResult(w, record) -} - // solutionRegistryProjection is what the gateway publishes about the registry. // It is a deliberate projection, not the record: the upstream URL stays inside // this process, because the only component that routes to it is this one. type solutionRegistryProjection struct { - Revision int64 `json:"revision"` - // LeaseSeconds is the liveness window this gateway grants a registrant. A - // consumer that caches this snapshot needs it to bound its own staleness - // the way this process does — past the lease nothing in the snapshot is - // provably still registered. Publishing it keeps that bound derived from - // the one place the lease is defined, instead of mirrored in a second - // literal that goes silently wrong the moment this one changes. - LeaseSeconds uint32 `json:"leaseSeconds"` - Solutions []solutionRegistrationProjection `json:"solutions"` + Revision int64 `json:"revision"` + Solutions []solutionRegistrationProjection `json:"solutions"` } type solutionRegistrationProjection struct { - ID string `json:"id"` - Publisher string `json:"publisher"` - Revision int64 `json:"revision"` - Status string `json:"status"` + ID string `json:"id"` + Publisher string `json:"publisher"` + Revision int64 `json:"revision"` + Status string `json:"status"` + // TargetID is the immutable solution target this record is declared under — + // the identity an installation names, and therefore the key a consumer joins + // its entitlements against. Empty for a record nothing declared, which no + // entitlement can ever match. + // + // It is published because the alternative is what was here before: the + // consumer joined on `id`, the route alias, which a later binding may claim — + // so a replacement solution inherited the predecessor's menu entry and its + // team exposure. The alias stays in the projection because it is what a URL + // is built from; it is no longer what anything is authorised by. + TargetID string `json:"targetId,omitempty"` ServiceAlias string `json:"serviceAlias,omitempty"` Manifest *string `json:"manifest,omitempty"` } @@ -524,18 +403,17 @@ func (g *Gateway) handleSolutionRegistrySnapshot(w http.ResponseWriter, r *http. httpError(w, http.StatusServiceUnavailable, "solution registry unavailable") return } - now := g.solutions.now() out := solutionRegistryProjection{ - Revision: revision, - LeaseSeconds: uint32(solutionLease.Seconds()), - Solutions: make([]solutionRegistrationProjection, 0, len(records)), + Revision: revision, + Solutions: make([]solutionRegistrationProjection, 0, len(records)), } for _, record := range records { projection := solutionRegistrationProjection{ ID: record.GetSolutionId(), Publisher: record.GetPublisher(), Revision: record.GetRevision(), - Status: solutionRegistryStatusLabel(record, now), + Status: solutionRegistryStatusLabel(record), + TargetID: record.GetDeclared().GetTargetId(), ServiceAlias: record.GetBackend().GetServiceAlias(), } if manifest := record.GetFrontend().GetManifest(); manifest != "" { @@ -546,24 +424,6 @@ func (g *Gateway) handleSolutionRegistrySnapshot(w http.ResponseWriter, r *http. writeSolutionJSON(w, http.StatusOK, out) } -// writeSolutionRegistrationResult echoes the revision the write landed at, so -// a registrant can hold it and drive its own compare-and-swap next time, and -// the status, so it learns immediately that its half alone is not yet serving. -func (g *Gateway) writeSolutionRegistrationResult(w http.ResponseWriter, record *accountsv1.SolutionRegistration) { - // A registration answer deliberately carries NO runtime boundary (issue - // #1015). A solution never needs one: accounts seals it into the capability - // from the solution's own credential, so nothing in a solution reads, sends - // or stores it, and echoing it here would widen who can see a value that is - // now stable for the life of the registration. The registry does not send - // one either — see solutionRegistrationProto in accounts. - writeSolutionJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "id": record.GetSolutionId(), - "revision": record.GetRevision(), - "status": solutionRegistryStatusLabel(record, g.solutions.now()), - }) -} - func writeSolutionJSON(w http.ResponseWriter, code int, payload any) { body, err := json.Marshal(payload) if err != nil { @@ -575,64 +435,6 @@ func writeSolutionJSON(w http.ResponseWriter, code int, payload any) { _, _ = w.Write(body) } -// writeSolutionRegistryError maps the registry's refusals onto HTTP. The -// conflict cases are distinguished from an outage so a registrant can tell "you -// are out of date, re-read and retry" from "the registry is down, back off". -func writeSolutionRegistryError(w http.ResponseWriter, err error) { - if errors.Is(err, errSolutionRegistryUnconfigured) { - httpError(w, http.StatusServiceUnavailable, "solution registry unavailable") - return - } - switch grpcstatus.Code(err) { - case codes.Aborted: - httpError(w, http.StatusConflict, "registration revision conflict") - case codes.FailedPrecondition: - httpError(w, http.StatusConflict, "registration conflicts with current state") - case codes.PermissionDenied: - httpError(w, http.StatusForbidden, "solution is registered to another publisher") - case codes.NotFound: - httpError(w, http.StatusNotFound, "solution not registered") - case codes.InvalidArgument: - if isSolutionAuditDeclarationRejection(err) { - // The registry will not admit the audit event types the manifest - // declares. That is a manifest (or a binding) to change, not a - // malformed write, so it has its own status and a stable error the - // frontend relays; the message says which rule was broken. - writeSolutionJSON(w, http.StatusUnprocessableEntity, map[string]any{ - "error": "registration_rejected", - "detail": grpcstatus.Convert(err).Message(), - }) - return - } - httpError(w, http.StatusBadRequest, "invalid registration") - default: - httpError(w, http.StatusBadGateway, "solution registry unavailable") - } -} - -// The google.rpc.ErrorInfo accounts attaches to a refused audit event -// declaration (adapters.SolutionAuditDeclarationRejectedReason and -// adapters.SolutionRegistryErrorDomain in accounts). The two strings are a wire -// contract; a test on each side pins them. -const ( - solutionAuditDeclarationRejectedReason = "SOLUTION_AUDIT_DECLARATION_REJECTED" - solutionRegistryErrorDomain = "accounts.saas.codefly.dev" -) - -// isSolutionAuditDeclarationRejection reports whether a registry refusal is -// the audit registry declining the manifest's declared event types. It reads -// the structured ErrorInfo, never the message. -func isSolutionAuditDeclarationRejection(err error) bool { - for _, detail := range grpcstatus.Convert(err).Details() { - if info, ok := detail.(*errdetails.ErrorInfo); ok && - info.GetReason() == solutionAuditDeclarationRejectedReason && - info.GetDomain() == solutionRegistryErrorDomain { - return true - } - } - return false -} - // isForbiddenUpstreamHost blocks upstream hosts that are credential-theft SSRF // sinks: the unspecified address, link-local addresses (which cover the // 169.254.169.254 cloud metadata IP), and the well-known metadata hostname. diff --git a/module/services/auth-gateway/code/gateway_solutions_test.go b/module/services/auth-gateway/code/gateway_solutions_test.go index b775f7ca4..a8e0e5143 100644 --- a/module/services/auth-gateway/code/gateway_solutions_test.go +++ b/module/services/auth-gateway/code/gateway_solutions_test.go @@ -5,24 +5,18 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "net/url" - "strings" "testing" "time" accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "github.com/stretchr/testify/require" - "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" grpcstatus "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" ) // registerSolutionUpstream wires a fully registered solution — both halves, -// live leases — into the registry behind gw and returns the upstream's capture +// admitted endpoints — into the registry behind gw and returns the upstream's capture // struct. Both halves matter: a solution with only one is deliberately not // routable (see TestGateway_Solution_BackendHalfAlone_NotRoutable). func registerSolutionUpstream(t *testing.T, gw *Gateway, id string) *fakeUpstream { @@ -43,31 +37,21 @@ func solutionRegistryFake(t *testing.T, gw *Gateway) *fakeSolutionRegistry { } // registerSolutionHalves writes both halves straight into the registry and -// refreshes the gateway's cache, standing in for the two self-registration -// calls a running solution makes. +// refreshes the gateway's cache, standing in for reconciled declarations. func registerSolutionHalves(t *testing.T, gw *Gateway, id, upstream string) { t.Helper() registry := solutionRegistryFake(t, gw) - ctx := context.Background() - _, err := registry.Put(ctx, &accountsv1.PutSolutionRegistrationRequest{ - SolutionId: id, - Publisher: id, - LeaseSeconds: 120, - Half: &accountsv1.PutSolutionRegistrationRequest_Frontend{ - Frontend: &accountsv1.SolutionFrontendRegistration{Manifest: `{"id":"` + id + `"}`}, - }, - }) - require.NoError(t, err) - _, err = registry.Put(ctx, &accountsv1.PutSolutionRegistrationRequest{ - SolutionId: id, - Publisher: id, - LeaseSeconds: 120, - Half: &accountsv1.PutSolutionRegistrationRequest_Backend{ - Backend: &accountsv1.SolutionBackendRegistration{Upstream: upstream, ServiceAlias: id}, - }, - }) - require.NoError(t, err) - require.NoError(t, gw.solutions.refresh(ctx)) + registry.mu.Lock() + registry.revision++ + registry.records[id] = &accountsv1.SolutionRegistration{ + SolutionId: id, Publisher: "solution:" + id, Revision: registry.revision, + Frontend: &accountsv1.SolutionFrontendBinding{Manifest: `{"id":"` + id + `"}`}, + Backend: &accountsv1.SolutionBackendBinding{Upstream: upstream, ServiceAlias: id}, + Declared: &accountsv1.SolutionDeclaredBinding{BindingId: "acme.test." + id, Generation: 1, Release: "acme/" + id + "@1.0.0", TargetId: fakeSolutionTarget(id), + Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION}, + } + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) } // A registered solution is still ALWAYS auth-required: no bearer means the @@ -280,159 +264,6 @@ func TestGateway_Solution_MissingID_NotFound(t *testing.T) { require.Equal(t, http.StatusNotFound, w.Code) } -// Both self-registration endpoints write to the one durable record, and the -// solution becomes routable on this replica as soon as the second half lands — -// no waiting for the reconcile tick, because a local write refreshes the cache. -func TestGateway_Solution_Register(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - - fake := &fakeUpstream{body: "solution-response"} - srv := httptest.NewServer(fake) - t.Cleanup(srv.Close) - - postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"audit","manifest":"{\"id\":\"audit\"}"}`, http.StatusOK) - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"`+srv.URL+`"}`, http.StatusOK) - - req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/ping", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "/v1/ping", fake.lastPath) -} - -// signSolutionRegistration mints the solution-bound credential registration -// requires, signed with the key the harness configured as the check's public -// half. -func signSolutionRegistration(t *testing.T, solutionID, subject string) string { - t.Helper() - c := solutionRegistrationClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "saas-starter", - Subject: subject, - Audience: jwt.ClaimStrings{solutionRegistrationAudience}, - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), - ID: uuid.Must(uuid.NewV7()).String(), - }, - Solution: solutionID, - } - signed, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c).SignedString(testRegistrationKey) - require.NoError(t, err) - return signed -} - -// solutionIDFromRegistration recovers the id a registration call names, so the -// helper can bind a credential to it. -func solutionIDFromRegistration(path, body string) string { - decoded := map[string]any{} - _ = json.Unmarshal([]byte(body), &decoded) - if id, ok := decoded["id"].(string); ok && id != "" { - return id - } - if u, err := url.Parse(path); err == nil { - if id := u.Query().Get("id"); id != "" { - return id - } - } - return "audit" -} - -// postSolutionRegistration drives one registration endpoint with a credential -// bound to the id it names and asserts the status, returning the decoded body. -func postSolutionRegistration(t *testing.T, gw *Gateway, path, body string, want int) map[string]any { - t.Helper() - req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) - id := solutionIDFromRegistration(path, body) - req.Header.Set(solutionRegistrationHeader, signSolutionRegistration(t, id, "solution:"+id)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, want, w.Code, "%s -> %s", path, w.Body.String()) - decoded := map[string]any{} - if w.Body.Len() > 0 { - _ = json.Unmarshal(w.Body.Bytes(), &decoded) - } - return decoded -} - -func TestGateway_Solution_Register_Rejects(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - - // Non-POST is rejected before any auth work. - getReq := httptest.NewRequest(http.MethodGet, "/solutions/_register", nil) - getW := httptest.NewRecorder() - gw.ServeHTTP(getW, getReq) - require.Equal(t, http.StatusMethodNotAllowed, getW.Code) - - // Missing id (with a valid internal token, to isolate the id check). - noIDReq := httptest.NewRequest(http.MethodPost, "/solutions/_register", strings.NewReader(`{"upstream":"http://x:80"}`)) - noIDReq.Header.Set(solutionRegistrationHeader, signSolutionRegistration(t, "audit", "solution:audit")) - noIDW := httptest.NewRecorder() - gw.ServeHTTP(noIDW, noIDReq) - require.Equal(t, http.StatusBadRequest, noIDW.Code) - - // Non-http scheme is rejected (no file://, no scheme-less host). - badReq := httptest.NewRequest(http.MethodPost, "/solutions/_register", strings.NewReader(`{"id":"x","upstream":"file:///etc/passwd"}`)) - badReq.Header.Set(solutionRegistrationHeader, signSolutionRegistration(t, "x", "solution:x")) - badW := httptest.NewRecorder() - gw.ServeHTTP(badW, badReq) - require.Equal(t, http.StatusBadRequest, badW.Code) -} - -// Registration is privileged: without a signed, solution-bound credential it is -// rejected before the upstream is ever stored, so an edge caller — including one -// holding the shared cluster-internal token, which used to be sufficient here — -// cannot point authenticated traffic at a host it controls. -func TestGateway_Solution_Register_RequiresInternalToken(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - - body := `{"id":"evil","upstream":"http://attacker.example"}` - - // No token. - noTok := httptest.NewRequest(http.MethodPost, "/solutions/_register", strings.NewReader(body)) - noTokW := httptest.NewRecorder() - gw.ServeHTTP(noTokW, noTok) - require.Equal(t, http.StatusUnauthorized, noTokW.Code) - - // Wrong token. - badTok := httptest.NewRequest(http.MethodPost, "/solutions/_register", strings.NewReader(body)) - badTok.Header.Set("X-Codefly-Internal-Token", "test-internal-token") - badTokW := httptest.NewRecorder() - gw.ServeHTTP(badTokW, badTok) - require.Equal(t, http.StatusUnauthorized, badTokW.Code) - - // The upstream was never registered, so even a fully authenticated user - // gets 502, not a proxy to attacker.example. - req := httptest.NewRequest(http.MethodGet, "/solutions/evil/x", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusBadGateway, w.Code) -} - -// A confused/compromised internal caller still cannot register a -// credential-theft SSRF sink (cloud metadata / link-local / unspecified). -// Loopback is intentionally allowed, so it is not asserted here. -func TestGateway_Solution_Register_RejectsSSRFHosts(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - - for _, upstream := range []string{ - "http://169.254.169.254/latest/meta-data/", - "http://metadata.google.internal/", - "http://[fe80::1]:9000", - "http://0.0.0.0:9000", - } { - req := httptest.NewRequest(http.MethodPost, "/solutions/_register", - strings.NewReader(`{"id":"x","upstream":"`+upstream+`"}`)) - req.Header.Set(solutionRegistrationHeader, signSolutionRegistration(t, "x", "solution:x")) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusBadRequest, w.Code, "upstream %q must be rejected", upstream) - } -} - // The authenticated solution data path consumes the same per-org/per-IP budget // as an equivalent catalog route: it must route through rateLimitThenProxy, not // proxy directly. Without that, /solutions//* would be an unmetered proxy an @@ -544,8 +375,10 @@ func TestGateway_Solution_PublicSurface_FailsOpenOnLimiterOutage(t *testing.T) { func TestGateway_Solution_BackendHalfAlone_NotRoutable(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://127.0.0.1:9"}`, http.StatusOK) + registerSolutionHalves(t, gw, "audit", "http://example.svc") + registry := solutionRegistryFake(t, gw) + registry.records["audit"].Frontend = nil + require.NoError(t, gw.solutions.refresh(context.Background())) req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/ping", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) @@ -561,10 +394,11 @@ func TestGateway_Solution_BackendHalfAlone_NotRoutable(t *testing.T) { func TestGateway_Solution_MismatchedContractVersions_NotRoutable(t *testing.T) { gw, _, _, priv := newGatewayHarness(t) - postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"audit","manifest":"{}","contractVersion":"v2"}`, http.StatusOK) - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://127.0.0.1:9","contractVersion":"v1"}`, http.StatusOK) + registerSolutionHalves(t, gw, "audit", "http://example.svc") + registry := solutionRegistryFake(t, gw) + registry.records["audit"].Frontend.ContractVersion = "v2" + registry.records["audit"].Backend.ContractVersion = "v1" + require.NoError(t, gw.solutions.refresh(context.Background())) req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/ping", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) @@ -583,10 +417,10 @@ func TestGateway_Solution_RestartRebuildsFromRegistry(t *testing.T) { registry := solutionRegistryFake(t, gw) restarted := newSolutionRegistryCache(registry) - upstream, resolution := restarted.resolve(context.Background(), "audit") + routing, resolution := restarted.resolveRouting(context.Background(), "audit") require.Equal(t, solutionRoutable, resolution) - require.Equal(t, "10.0.0.7:8080", upstream.Host) + require.Equal(t, "10.0.0.7:8080", routing.Upstream.Host) } // A registration made through one replica reaches the others: the second @@ -608,7 +442,7 @@ func TestGateway_Solution_ReplicasConvergeOnSameRevision(t *testing.T) { require.True(t, loaded) require.Equal(t, writerRevision, otherRevision, "both replicas converge on one registry revision") - _, resolution := other.resolve(context.Background(), "audit") + _, resolution := other.resolveRouting(context.Background(), "audit") require.Equal(t, solutionRoutable, resolution) } @@ -626,33 +460,18 @@ func TestGateway_Solution_CacheMissRefreshesOnceWithinFloor(t *testing.T) { // Past the refresh floor, so the miss is allowed to consult the registry. lagging.now = func() time.Time { return time.Now().Add(solutionRefreshFloor) } before := registry.listCalls - _, resolution := lagging.resolve(context.Background(), "audit") + _, resolution := lagging.resolveRouting(context.Background(), "audit") require.Equal(t, solutionRoutable, resolution) require.Equal(t, before+1, registry.listCalls) // A second miss inside the floor does not read the registry again. lagging.now = time.Now before = registry.listCalls - _, resolution = lagging.resolve(context.Background(), "ghost") + _, resolution = lagging.resolveRouting(context.Background(), "ghost") require.Equal(t, solutionUnregistered, resolution) require.Equal(t, before, registry.listCalls) } -// A lease that lapsed suspends routing without discarding the registration: -// the record is still there, and the answer says "not active" rather than "not -// registered". -func TestGateway_Solution_ExpiredLease_NotRoutable(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - registerSolutionHalves(t, gw, "audit", "http://10.0.0.7:8080") - - registry := solutionRegistryFake(t, gw) - registry.records["audit"].Backend.LeaseExpiresAt = timestamppb.New(time.Now().Add(-time.Minute)) - require.NoError(t, gw.solutions.refresh(context.Background())) - - _, resolution := gw.solutions.resolve(context.Background(), "audit") - require.Equal(t, solutionNotActive, resolution) -} - // A replica that has never loaded a snapshot cannot tell an unregistered // solution from a registered one, so it fails closed and says the registry is // unavailable — a different answer from "not registered". @@ -679,191 +498,11 @@ func TestGateway_Solution_RegistryOutageKeepsLastSnapshot(t *testing.T) { registry.listErr = grpcstatus.Error(codes.Unavailable, "registry down") require.Error(t, gw.solutions.refresh(context.Background())) - _, resolution := gw.solutions.resolve(context.Background(), "audit") + _, resolution := gw.solutions.resolveRouting(context.Background(), "audit") require.Equal(t, solutionRoutable, resolution) require.NotNil(t, fake) } -// Deregistration removes routing on this replica in the same request, and a -// plain re-registration afterwards carries no compare-and-swap token — which -// is what the registry refuses, so a retiring deployment's retry cannot -// resurrect the removed solution. Naming `reactivate` is the authorized way -// back. -func TestGateway_Solution_Deregister_BlocksResurrection(t *testing.T) { - gw, _, _, priv := newGatewayHarness(t) - registerSolutionUpstream(t, gw, "audit") - registry := solutionRegistryFake(t, gw) - - del := httptest.NewRequest(http.MethodDelete, "/solutions/_register?id=audit", nil) - del.Header.Set(solutionRegistrationHeader, - signSolutionRegistration(t, "audit", "solution:audit")) - delW := httptest.NewRecorder() - gw.ServeHTTP(delW, del) - require.Equal(t, http.StatusOK, delW.Code) - require.Equal(t, []string{"audit"}, registry.deletes) - tombstoneRevision := registry.records["audit"].GetRevision() - - req := httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/ping", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w := httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusBadGateway, w.Code, "a tombstoned solution is gone from the live snapshot") - - // A retiring deployment's heartbeat: no revision named, so the tombstone - // refuses it outright rather than recreating the registration. - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://10.0.0.7:8080"}`, http.StatusConflict) - require.Nil(t, registry.puts[len(registry.puts)-1].ExpectedRevision, - "a plain retry must name no revision") - - req = httptest.NewRequest(http.MethodGet, "/solutions/audit/v1/ping", nil) - req.Header.Set("authorization", "Bearer "+signValidToken(t, priv)) - w = httptest.NewRecorder() - gw.ServeHTTP(w, req) - require.Equal(t, http.StatusBadGateway, w.Code, "the refused retry did not resurrect the route") - - // An explicit re-registration names the tombstone's own revision and is - // admitted, so a removed solution can be deliberately brought back. - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://10.0.0.7:8080","reactivate":true}`, http.StatusOK) - require.NotNil(t, registry.puts[len(registry.puts)-1].ExpectedRevision, - "an authorized re-registration names the tombstone's revision") - require.Equal(t, tombstoneRevision, registry.puts[len(registry.puts)-1].GetExpectedRevision(), - "and it must be the tombstone's revision, not a live record's") -} - -// Deregistration is privileged, exactly like registration. -func TestGateway_Solution_Deregister_RequiresInternalToken(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - registerSolutionUpstream(t, gw, "audit") - - del := httptest.NewRequest(http.MethodDelete, "/solutions/_register?id=audit", nil) - w := httptest.NewRecorder() - gw.ServeHTTP(w, del) - - require.Equal(t, http.StatusUnauthorized, w.Code) - require.Empty(t, solutionRegistryFake(t, gw).deletes) -} - -// Changing an existing half rides on the revision this replica last saw, so -// two concurrent updates serialize on the registry rather than racing. -func TestGateway_Solution_Register_CarriesCachedRevision(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - registerSolutionUpstream(t, gw, "audit") - registry := solutionRegistryFake(t, gw) - before := registry.records["audit"].GetRevision() - - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://10.0.0.9:8080"}`, http.StatusOK) - - last := registry.puts[len(registry.puts)-1] - require.NotNil(t, last.ExpectedRevision) - require.Equal(t, before, *last.ExpectedRevision) -} - -// A registry refusal is relayed as a conflict, not as a success or a generic -// 502: the registrant has to re-read and retry, and must be told so. -// A replica that has not seen a registration cannot name its revision, so the -// registry refuses the write — recoverably. While that refusal shared a code -// with the tombstone refusal, which must never be retried, the gateway retried -// neither, and a legitimate upstream change 409'd until the next reconcile tick. -func TestGateway_Solution_ColdCacheRecoversRegistrationChange(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - registerSolutionUpstream(t, gw, "audit") - registry := solutionRegistryFake(t, gw) - before := len(registry.puts) - - // The state right after a restart, or a registration that landed on another - // replica moments ago. - gw.solutions.mu.Lock() - gw.solutions.records = map[string]*accountsv1.SolutionRegistration{} - gw.solutions.mu.Unlock() - - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://10.0.0.9:8080"}`, http.StatusOK) - - require.Equal(t, before+2, len(registry.puts), - "the refused write is retried once, against a freshly read snapshot") - require.Nil(t, registry.puts[before].ExpectedRevision, - "the cold attempt can name no revision") - require.NotNil(t, registry.puts[before+1].ExpectedRevision, - "the retry names the revision it just read") -} - -func TestGateway_Solution_Register_RelaysConflict(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - solutionRegistryFake(t, gw).putErr = grpcstatus.Error(codes.Aborted, "stale") - - postSolutionRegistration(t, gw, "/solutions/_register", - `{"id":"audit","upstream":"http://10.0.0.9:8080"}`, http.StatusConflict) -} - -// A refused audit event declaration carries a structured reason; only that -// becomes 422 registration_rejected. Any other InvalidArgument stays 400. -func TestGateway_Solution_FrontendRegister_RelaysARejectedDeclaration(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - rejected, err := grpcstatus.New(codes.InvalidArgument, "namespace \"acme\" is not bound to solution \"audit\""). - WithDetails(&errdetails.ErrorInfo{ - Reason: "SOLUTION_AUDIT_DECLARATION_REJECTED", - Domain: "accounts.saas.codefly.dev", - }) - require.NoError(t, err) - solutionRegistryFake(t, gw).putErr = rejected.Err() - body := postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"audit","manifest":"{\"id\":\"audit\"}"}`, http.StatusUnprocessableEntity) - require.Equal(t, "registration_rejected", body["error"]) - require.Contains(t, body["detail"], "not bound") - - for name, refusal := range map[string]error{ - "no detail": grpcstatus.Error(codes.InvalidArgument, "namespace is not bound"), - "another reason": mustWithInfo(t, "SOMETHING_ELSE", "accounts.saas.codefly.dev"), - "another domain": mustWithInfo(t, "SOLUTION_AUDIT_DECLARATION_REJECTED", "example.com"), - } { - t.Run(name, func(t *testing.T) { - solutionRegistryFake(t, gw).putErr = refusal - postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"audit","manifest":"{\"id\":\"audit\"}"}`, http.StatusBadRequest) - }) - } -} - -// The reason and domain are a wire contract with accounts, which holds the -// same two strings. -func TestSolutionAuditDeclarationRejectedReasonIsPinned(t *testing.T) { - require.Equal(t, "SOLUTION_AUDIT_DECLARATION_REJECTED", solutionAuditDeclarationRejectedReason, - "the declaration-rejection ErrorInfo moved; move accounts' copy with it") - require.Equal(t, "accounts.saas.codefly.dev", solutionRegistryErrorDomain) -} - -func mustWithInfo(t *testing.T, reason, domain string) error { - t.Helper() - st, err := grpcstatus.New(codes.InvalidArgument, "refused"). - WithDetails(&errdetails.ErrorInfo{Reason: reason, Domain: domain}) - require.NoError(t, err) - return st.Err() -} - -// The frontend half is registered through the gateway because the frontend has -// no route to the accounts internal listener. It is the same privileged -// endpoint, and it refuses a body that is not a JSON document. -func TestGateway_Solution_FrontendRegister(t *testing.T) { - gw, _, _, _ := newGatewayHarness(t) - - noTok := httptest.NewRequest(http.MethodPost, "/solutions/_frontend", - strings.NewReader(`{"id":"audit","manifest":"{}"}`)) - noTokW := httptest.NewRecorder() - gw.ServeHTTP(noTokW, noTok) - require.Equal(t, http.StatusUnauthorized, noTokW.Code) - - postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"audit","manifest":"not json"}`, http.StatusBadRequest) - - body := postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"audit","manifest":"{\"id\":\"audit\"}"}`, http.StatusOK) - require.Equal(t, "pending", body["status"], "one half alone is a durable but pending registration") - require.NotZero(t, body["revision"]) -} - // The snapshot endpoint is the frontend's read path and an operator's // diagnostic. It is a projection: it carries the states that tell those cases // apart and deliberately not the upstream URL, which only this process routes @@ -871,8 +510,10 @@ func TestGateway_Solution_FrontendRegister(t *testing.T) { func TestGateway_Solution_RegistrySnapshot(t *testing.T) { gw, _, _, _ := newGatewayHarness(t) registerSolutionUpstream(t, gw, "audit") - postSolutionRegistration(t, gw, "/solutions/_frontend", - `{"id":"reports","manifest":"{\"id\":\"reports\"}"}`, http.StatusOK) + registerSolutionHalves(t, gw, "reports", "http://example.svc") + registry := solutionRegistryFake(t, gw) + registry.records["reports"].Backend = nil + require.NoError(t, gw.solutions.refresh(context.Background())) noTok := httptest.NewRequest(http.MethodGet, "/solutions/_registry", nil) noTokW := httptest.NewRecorder() @@ -893,9 +534,5 @@ func TestGateway_Solution_RegistrySnapshot(t *testing.T) { require.Equal(t, "active", snapshot.Solutions[0].Status) require.Equal(t, "reports", snapshot.Solutions[1].ID) require.Equal(t, "pending", snapshot.Solutions[1].Status) - // A consumer bounds its own cache staleness with this, so it has to travel - // with the snapshot rather than being mirrored in the consumer. - require.Equal(t, uint32(solutionLease.Seconds()), snapshot.LeaseSeconds, - "the snapshot carries the lease window this gateway grants") require.NotContains(t, w.Body.String(), "upstream") } diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go index 36410e1f5..f20772db1 100644 --- a/module/services/auth-gateway/code/gateway_test.go +++ b/module/services/auth-gateway/code/gateway_test.go @@ -134,14 +134,20 @@ func newGatewayHarness(t *testing.T) (*Gateway, *fakeUpstream, *fakeUpstream, ed "frontend": frontendURL, } + solutions := newFakeSolutionRegistry() gateway := NewGateway( authz, matcher, upstreams, nil, - newFakeSolutionRegistry(), + solutions, newFakeClientRegistry(), ) + // The solution proxy consults the entitlement authority before it forwards + // anything, so a harness with none would answer 503 to every solution test. + // This stands in for an organization that installed everything the registry + // holds; a test about admission substitutes a narrower one. + gateway.solutionEntitlements = &installedEverythingRegistered{registry: solutions} return gateway, apiFake, frontendFake, priv } diff --git a/module/services/auth-gateway/code/gateway_work_context_edge_test.go b/module/services/auth-gateway/code/gateway_work_context_edge_test.go new file mode 100644 index 000000000..dab26d4f3 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_work_context_edge_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "crypto/ed25519" + "net/http" + "net/http/httptest" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/sdk-go/workcontext" + "github.com/stretchr/testify/require" +) + +// THE EDGE RULE (issue #952): resolve the route, then judge the capability +// against THAT route's audience. +// +// Before this, the edge verified a presented Work Context before any routing and +// with no audience expectation at all — so a capability minted for one solution +// verified cleanly on another solution's route and was forwarded there, and the +// callee's own audience check was the only thing that could notice. The host now +// derives the expected audience from the route it resolved and refuses the +// mismatch at the edge. + +// mintWorkContextAudience signs a token for an arbitrary audience, which is what +// an A-for-B test needs: the same key, the same window, a different consumer. +func mintWorkContextAudience(t *testing.T, kid string, priv ed25519.PrivateKey, audience string) workcontext.WorkContextToken { + t.Helper() + signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ + Issuer: "saas-starter", + KeyID: kid, + PrivateKey: priv, + }) + require.NoError(t, err) + token, _, err := signer.StartTask(workcontext.StartTaskInput{ + Audience: audience, + TenantID: "tenant-1", + OwnerPrincipalID: "user-1", + TaskID: "task-1", + SessionID: "session-1", + AuthorityScopes: []*basev0.WorkScopeV1{ + {ResourceKind: "audit", Actions: []string{"read"}}, + }, + }) + require.NoError(t, err) + return token +} + +// edgeHarness wires a gateway with a working Work Context verifier, and hands back +// the accounts upstream so a test can assert the capability never reached it. +func edgeHarness(t *testing.T) (*Gateway, *fakeUpstream, ed25519.PrivateKey, ed25519.PrivateKey, string) { + t.Helper() + gw, apiFake, _, accessPriv := newGatewayHarness(t) + pub, wcPriv := mustEd25519(t) + const kid = "key-1" + server := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) + gw.workContext = newWorkContextVerifier(server.URL) + return gw, apiFake, accessPriv, wcPriv, kid +} + +// Rule 1 and rule 5 together: a capability minted for ONE solution is refused on +// ANOTHER solution's route, and refused as an invalid capability rather than as a +// missing route. +// +// The two halves matter separately. A 404 would say the route does not exist, +// which is false and sends an operator to the registry; the 401 says the +// capability is not valid here, which is what happened. +func TestGatewayEdge_ACapabilityForOneSolutionIsRefusedOnAnothers(t *testing.T) { + gw, _, accessPriv, wcPriv, kid := edgeHarness(t) + registerSolutionUpstream(t, gw, "example-go") + fake := registerSolutionUpstream(t, gw, "other-solution") + + // Minted for example-go's binding, presented to other-solution's route. + foreign := mintWorkContextAudience(t, kid, wcPriv, + solutionRouteAudience("acme.test.example-go")) + req := httptest.NewRequest(http.MethodGet, "/solutions/other-solution/data", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) + req.Header.Set(workcontext.WorkContextHeaderName, foreign.Encoded()) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code, + "A-for-B must be refused as an invalid capability, not answered as a missing route") + require.NotEqual(t, http.StatusNotFound, w.Code) + require.Nil(t, fake.lastHeaders, "the foreign capability must not reach the upstream") + + // And the SAME token on its own route is forwarded, so the refusal above is + // about the audience and not about the token. + own := registerSolutionUpstream(t, gw, "example-go") + ok := httptest.NewRequest(http.MethodGet, "/solutions/example-go/data", nil) + ok.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) + ok.Header.Set(workcontext.WorkContextHeaderName, foreign.Encoded()) + w = httptest.NewRecorder() + gw.ServeHTTP(w, ok) + require.Equal(t, http.StatusOK, w.Code, + "the same capability on its own binding's route must be accepted, or the test proves nothing about the audience") + require.Equal(t, foreign.Encoded(), own.lastHeaders.Get(workcontext.WorkContextHeaderName)) +} + +// Rule 2: a path that resolves to NO route is answered as the unexposed path it +// is, and the verifier is never called. +// +// Asserted on the JWKS server's hit count, not on the status code: a 404 proves +// the answer, but only "the key set was never fetched" proves the ordering — that +// the route was resolved first and the token never judged. +func TestGatewayEdge_AnUnresolvedRouteNeverReachesTheVerifier(t *testing.T) { + gw, _, accessPriv, wcPriv, kid := edgeHarness(t) + var hits int64 + pub, _ := mustEd25519(t) + server := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), &hits) + gw.workContext = newWorkContextVerifier(server.URL) + + token := mintWorkContextAudience(t, kid, wcPriv, "solution:anything") + req := httptest.NewRequest(http.MethodGet, "/nothing/this/host/exposes", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) + req.Header.Set(workcontext.WorkContextHeaderName, token.Encoded()) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Zero(t, hits, "the key set must not be fetched for a request that resolves to no route") +} + +// Rule 4: a capability addressed to the GATEWAY ITSELF is refused. +// +// This gateway is a forwarding hop: it inspects and never consumes. A token +// naming the hop is one nobody will consume, and it is what a caller would mint +// to make the hop treat a capability as its own. Refused by name rather than +// forwarded for a callee to puzzle over. +func TestGatewayEdge_ACapabilityAddressedToTheGatewayIsRefused(t *testing.T) { + gw, apiFake, accessPriv, wcPriv, kid := edgeHarness(t) + + token := mintWorkContextAudience(t, kid, wcPriv, gatewayAudience) + req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) + req.Header.Set(workcontext.WorkContextHeaderName, token.Encoded()) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, apiFake.lastHeaders, "a capability addressed to the hop must not be forwarded") + + // Directly, so the refusal is pinned to the rule and not to a routing accident. + err := gw.workContext.Verify(context.Background(), token, "") + require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + require.Contains(t, err.Error(), "forwarding hop") +} + +// Rule 3: a declared MODULE route expects the module capability audience, and a +// solution capability is refused there. +func TestGatewayEdge_AModuleRouteExpectsTheModuleAudience(t *testing.T) { + gw, _, accessPriv, wcPriv, kid := edgeHarness(t) + fake := declareModule(t, gw, "example-module") + + solutionToken := mintWorkContextAudience(t, kid, wcPriv, solutionRouteAudience("acme.test.example-go")) + req := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) + req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) + req.Header.Set(workcontext.WorkContextHeaderName, solutionToken.Encoded()) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Nil(t, fake.lastHeaders) + + moduleToken := mintWorkContextAudience(t, kid, wcPriv, moduleCapabilitiesAudience) + ok := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) + ok.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) + ok.Header.Set(workcontext.WorkContextHeaderName, moduleToken.Encoded()) + w = httptest.NewRecorder() + gw.ServeHTTP(w, ok) + require.Equal(t, http.StatusOK, w.Code, + "the module audience must be accepted on a module route, or the refusal above proves nothing") +} + +// The solution route's expected audience is keyed on the BINDING, not the alias. +// +// An alias is deliberately reusable, so an audience keyed on it would let a +// replacement binding under the same route be addressed as its predecessor — the +// same reuse hole the artifact-approval authority and the boundary derivation both +// had to close. Asserted by value against the prefix, so a change to the shape +// moves this test rather than letting it pass against a stale spelling. +func TestGatewayEdge_TheSolutionAudienceIsKeyedOnTheBinding(t *testing.T) { + require.Equal(t, "solution:acme.test.example", solutionRouteAudience("acme.test.example")) + require.Equal(t, solutionAudiencePrefix+"b", solutionRouteAudience("b")) + require.Empty(t, solutionRouteAudience(""), + "no binding means no expectation; the surrounding refusals are what make that unreachable") + // The two constants this gateway cannot import from accounts, pinned by value. + require.Equal(t, "solution:", solutionAudiencePrefix) + require.Equal(t, "module-capabilities", moduleCapabilitiesAudience) +} diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index 35024443d..1676849e0 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 + github.com/codefly-dev/core v0.9.0 github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e github.com/envoyproxy/go-control-plane/envoy v1.39.0 diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 1162c0299..2429ee792 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -44,8 +44,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index a1ebb1a3e..b644d9e3f 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -233,6 +233,10 @@ func main() { // reach this replica without restarting it. clientRegistry := &accountsClientRegistry{conn: internalAPIConn, internalToken: authz.internalToken} gateway := NewGateway(authz, matcher, upstreams, rateLimiter, solutionRegistry, clientRegistry) + // The per-viewer solution entitlement read (#949), over the same internal + // connection. It is not a cache: the answer is per viewer and must not + // outlive a revoke, so every request asks. + gateway.solutionEntitlements = &accountsSolutionEntitlements{conn: internalAPIConn, internalToken: authz.internalToken} go gateway.solutions.reconcile(ctx) go gateway.clients.reconcile(ctx) if apiHTTPURL != "" { diff --git a/module/services/auth-gateway/code/perimeter_workos_free_test.go b/module/services/auth-gateway/code/perimeter_workos_free_test.go index 44f93bf10..9432fe2b8 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -154,10 +154,22 @@ var httpAllowedPerimeterFiles = map[string]bool{ // internal gRPC hop to accounts that mints the credential; it reaches no // external IdP. "gateway_solution_credential.go": true, - // gateway_modules.go proxies runtime-registered composed-module REST - // upstreams. It runs the same ext_authz Check as any protected route and + // gateway_modules.go holds the module credential exchanges and the upstream + // transport. It runs the same ext_authz Check as any protected route and // reaches no external IdP — the ext_authz check remains the token authority. "gateway_modules.go": true, + // gateway_module_routes.go proxies a DECLARED composed module's REST surface + // at /v1//*. Its network calls are the same two every protected route + // makes — the ext_authz Check, which stays the token authority, and the proxy + // hop to the declared upstream — and the upstream itself is read from the + // declared registry rather than named by a caller, through the same URL policy + // a solution upstream passes. It reaches no external IdP. + "gateway_module_routes.go": true, + // gateway_solution_entitlements.go answers what the calling viewer may use. It + // runs the same ext_authz Check as any protected route — so the token authority + // is unchanged and local — and its only network call is the internal gRPC hop to + // accounts for the installation and grant state. It reaches no external IdP. + "gateway_solution_entitlements.go": true, // gateway_cors.go answers cross-origin access for registered clients. It // decides from the registry snapshot and from the client id the ext_authz // check already resolved from a locally verified token; it makes no network diff --git a/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go new file mode 100644 index 000000000..b3c047b1a --- /dev/null +++ b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go @@ -0,0 +1,322 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/accounts/v1/solution_entitlements.proto + +package accountsv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +type SolutionEntitlement struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstallationId string `protobuf:"bytes,2,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + RootScopeNodeId string `protobuf:"bytes,3,opt,name=root_scope_node_id,json=rootScopeNodeId,proto3" json:"root_scope_node_id,omitempty"` + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + Healthy bool `protobuf:"varint,4,opt,name=healthy,proto3" json:"healthy,omitempty"` + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + TargetId string `protobuf:"bytes,5,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SolutionEntitlement) Reset() { + *x = SolutionEntitlement{} + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SolutionEntitlement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolutionEntitlement) ProtoMessage() {} + +func (x *SolutionEntitlement) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SolutionEntitlement.ProtoReflect.Descriptor instead. +func (*SolutionEntitlement) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP(), []int{0} +} + +func (x *SolutionEntitlement) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +func (x *SolutionEntitlement) GetRootScopeNodeId() string { + if x != nil { + return x.RootScopeNodeId + } + return "" +} + +func (x *SolutionEntitlement) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *SolutionEntitlement) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +type ListSolutionEntitlementsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + SubjectId string `protobuf:"bytes,2,opt,name=subject_id,json=subjectId,proto3" json:"subject_id,omitempty"` + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque cursor from a previous response's next_page_token; empty for page one. + PageToken string `protobuf:"bytes,4,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionEntitlementsRequest) Reset() { + *x = ListSolutionEntitlementsRequest{} + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSolutionEntitlementsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSolutionEntitlementsRequest) ProtoMessage() {} + +func (x *ListSolutionEntitlementsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSolutionEntitlementsRequest.ProtoReflect.Descriptor instead. +func (*ListSolutionEntitlementsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP(), []int{1} +} + +func (x *ListSolutionEntitlementsRequest) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *ListSolutionEntitlementsRequest) GetSubjectId() string { + if x != nil { + return x.SubjectId + } + return "" +} + +func (x *ListSolutionEntitlementsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListSolutionEntitlementsRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +type ListSolutionEntitlementsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entitlements []*SolutionEntitlement `protobuf:"bytes,1,rep,name=entitlements,proto3" json:"entitlements,omitempty"` + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionEntitlementsResponse) Reset() { + *x = ListSolutionEntitlementsResponse{} + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSolutionEntitlementsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSolutionEntitlementsResponse) ProtoMessage() {} + +func (x *ListSolutionEntitlementsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_entitlements_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSolutionEntitlementsResponse.ProtoReflect.Descriptor instead. +func (*ListSolutionEntitlementsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP(), []int{2} +} + +func (x *ListSolutionEntitlementsResponse) GetEntitlements() []*SolutionEntitlement { + if x != nil { + return x.Entitlements + } + return nil +} + +func (x *ListSolutionEntitlementsResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +var File_saas_accounts_v1_solution_entitlements_proto protoreflect.FileDescriptor + +const file_saas_accounts_v1_solution_entitlements_proto_rawDesc = "" + + "\n" + + ",saas/accounts/v1/solution_entitlements.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\"\xdb\x01\n" + + "\x13SolutionEntitlement\x121\n" + + "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\x125\n" + + "\x12root_scope_node_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0frootScopeNodeId\x12\x18\n" + + "\ahealthy\x18\x04 \x01(\bR\ahealthy\x12%\n" + + "\ttarget_id\x18\x05 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetIdJ\x04\b\x01\x10\x02R\x13solution_identifier\"\xb3\x01\n" + + "\x1fListSolutionEntitlementsRequest\x12\x1f\n" + + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12'\n" + + "\n" + + "subject_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsubjectId\x12'\n" + + "\tpage_size\x18\x03 \x01(\x05B\n" + + "\xbaH\a\x1a\x05\x18\xf4\x03(\x00R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x04 \x01(\tR\tpageToken\"\x95\x01\n" + + " ListSolutionEntitlementsResponse\x12I\n" + + "\fentitlements\x18\x01 \x03(\v2%.saas.accounts.v1.SolutionEntitlementR\fentitlements\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageTokenB\xc5\x01\n" + + "\x14com.saas.accounts.v1B\x19SolutionEntitlementsProtoP\x01Z0auth-gateway/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" + +var ( + file_saas_accounts_v1_solution_entitlements_proto_rawDescOnce sync.Once + file_saas_accounts_v1_solution_entitlements_proto_rawDescData []byte +) + +func file_saas_accounts_v1_solution_entitlements_proto_rawDescGZIP() []byte { + file_saas_accounts_v1_solution_entitlements_proto_rawDescOnce.Do(func() { + file_saas_accounts_v1_solution_entitlements_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_entitlements_proto_rawDesc), len(file_saas_accounts_v1_solution_entitlements_proto_rawDesc))) + }) + return file_saas_accounts_v1_solution_entitlements_proto_rawDescData +} + +var file_saas_accounts_v1_solution_entitlements_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_saas_accounts_v1_solution_entitlements_proto_goTypes = []any{ + (*SolutionEntitlement)(nil), // 0: saas.accounts.v1.SolutionEntitlement + (*ListSolutionEntitlementsRequest)(nil), // 1: saas.accounts.v1.ListSolutionEntitlementsRequest + (*ListSolutionEntitlementsResponse)(nil), // 2: saas.accounts.v1.ListSolutionEntitlementsResponse +} +var file_saas_accounts_v1_solution_entitlements_proto_depIdxs = []int32{ + 0, // 0: saas.accounts.v1.ListSolutionEntitlementsResponse.entitlements:type_name -> saas.accounts.v1.SolutionEntitlement + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_saas_accounts_v1_solution_entitlements_proto_init() } +func file_saas_accounts_v1_solution_entitlements_proto_init() { + if File_saas_accounts_v1_solution_entitlements_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_entitlements_proto_rawDesc), len(file_saas_accounts_v1_solution_entitlements_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_saas_accounts_v1_solution_entitlements_proto_goTypes, + DependencyIndexes: file_saas_accounts_v1_solution_entitlements_proto_depIdxs, + MessageInfos: file_saas_accounts_v1_solution_entitlements_proto_msgTypes, + }.Build() + File_saas_accounts_v1_solution_entitlements_proto = out.File + file_saas_accounts_v1_solution_entitlements_proto_goTypes = nil + file_saas_accounts_v1_solution_entitlements_proto_depIdxs = nil +} diff --git a/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go index 4087a6c08..c0e369530 100644 --- a/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go +++ b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_registry.pb.go @@ -11,7 +11,6 @@ import ( sync "sync" unsafe "unsafe" - _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" @@ -24,28 +23,22 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. type SolutionRegistrationStatus int32 const ( SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_UNSPECIFIED SolutionRegistrationStatus = 0 - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_ACTIVE SolutionRegistrationStatus = 1 - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_PENDING SolutionRegistrationStatus = 2 - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_EXPIRED SolutionRegistrationStatus = 3 // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE SolutionRegistrationStatus = 4 - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SolutionRegistrationStatus_SOLUTION_REGISTRATION_STATUS_TOMBSTONED SolutionRegistrationStatus = 5 ) @@ -55,7 +48,6 @@ var ( 0: "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED", 1: "SOLUTION_REGISTRATION_STATUS_ACTIVE", 2: "SOLUTION_REGISTRATION_STATUS_PENDING", - 3: "SOLUTION_REGISTRATION_STATUS_EXPIRED", 4: "SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE", 5: "SOLUTION_REGISTRATION_STATUS_TOMBSTONED", } @@ -63,7 +55,6 @@ var ( "SOLUTION_REGISTRATION_STATUS_UNSPECIFIED": 0, "SOLUTION_REGISTRATION_STATUS_ACTIVE": 1, "SOLUTION_REGISTRATION_STATUS_PENDING": 2, - "SOLUTION_REGISTRATION_STATUS_EXPIRED": 3, "SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE": 4, "SOLUTION_REGISTRATION_STATUS_TOMBSTONED": 5, } @@ -96,16 +87,80 @@ func (SolutionRegistrationStatus) EnumDescriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{0} } +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +type SolutionDeclaredKind int32 + +const ( + SolutionDeclaredKind_SOLUTION_DECLARED_KIND_UNSPECIFIED SolutionDeclaredKind = 0 + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SolutionDeclaredKind_SOLUTION_DECLARED_KIND_SOLUTION SolutionDeclaredKind = 1 + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE SolutionDeclaredKind = 2 +) + +// Enum value maps for SolutionDeclaredKind. +var ( + SolutionDeclaredKind_name = map[int32]string{ + 0: "SOLUTION_DECLARED_KIND_UNSPECIFIED", + 1: "SOLUTION_DECLARED_KIND_SOLUTION", + 2: "SOLUTION_DECLARED_KIND_MODULE", + } + SolutionDeclaredKind_value = map[string]int32{ + "SOLUTION_DECLARED_KIND_UNSPECIFIED": 0, + "SOLUTION_DECLARED_KIND_SOLUTION": 1, + "SOLUTION_DECLARED_KIND_MODULE": 2, + } +) + +func (x SolutionDeclaredKind) Enum() *SolutionDeclaredKind { + p := new(SolutionDeclaredKind) + *p = x + return p +} + +func (x SolutionDeclaredKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SolutionDeclaredKind) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_solution_registry_proto_enumTypes[1].Descriptor() +} + +func (SolutionDeclaredKind) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_solution_registry_proto_enumTypes[1] +} + +func (x SolutionDeclaredKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SolutionDeclaredKind.Descriptor instead. +func (SolutionDeclaredKind) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{1} +} + // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. type SolutionFrontendBinding struct { state protoimpl.MessageState `protogen:"open.v1"` Revision int64 `protobuf:"varint,1,opt,name=revision,proto3" json:"revision,omitempty"` Manifest string `protobuf:"bytes,2,opt,name=manifest,proto3" json:"manifest,omitempty"` ContractVersion string `protobuf:"bytes,3,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - LeaseExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=lease_expires_at,json=leaseExpiresAt,proto3" json:"lease_expires_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -161,13 +216,6 @@ func (x *SolutionFrontendBinding) GetContractVersion() string { return "" } -func (x *SolutionFrontendBinding) GetLeaseExpiresAt() *timestamppb.Timestamp { - if x != nil { - return x.LeaseExpiresAt - } - return nil -} - // SolutionBackendBinding is the stored backend half: the upstream the gateway // proxies to and the logical service alias it is addressed by. type SolutionBackendBinding struct { @@ -176,7 +224,6 @@ type SolutionBackendBinding struct { Upstream string `protobuf:"bytes,2,opt,name=upstream,proto3" json:"upstream,omitempty"` ServiceAlias string `protobuf:"bytes,3,opt,name=service_alias,json=serviceAlias,proto3" json:"service_alias,omitempty"` ContractVersion string `protobuf:"bytes,4,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - LeaseExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=lease_expires_at,json=leaseExpiresAt,proto3" json:"lease_expires_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -239,21 +286,13 @@ func (x *SolutionBackendBinding) GetContractVersion() string { return "" } -func (x *SolutionBackendBinding) GetLeaseExpiresAt() *timestamppb.Timestamp { - if x != nil { - return x.LeaseExpiresAt - } - return nil -} - // SolutionRegistration is the canonical record. revision is drawn from one // registry-wide sequence, so revisions are comparable across records and a // consumer can order two snapshots without a wall clock. type SolutionRegistration struct { state protoimpl.MessageState `protogen:"open.v1"` SolutionId string `protobuf:"bytes,1,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. Publisher string `protobuf:"bytes,2,opt,name=publisher,proto3" json:"publisher,omitempty"` Revision int64 `protobuf:"varint,3,opt,name=revision,proto3" json:"revision,omitempty"` Status SolutionRegistrationStatus `protobuf:"varint,4,opt,name=status,proto3,enum=saas.accounts.v1.SolutionRegistrationStatus" json:"status,omitempty"` @@ -261,20 +300,10 @@ type SolutionRegistration struct { Backend *SolutionBackendBinding `protobuf:"bytes,6,opt,name=backend,proto3,oneof" json:"backend,omitempty"` UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` TombstonedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=tombstoned_at,json=tombstonedAt,proto3,oneof" json:"tombstoned_at,omitempty"` - // The seed a solution's runtime boundary is derived from (issue #1015). - // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - RuntimeBoundary string `protobuf:"bytes,9,opt,name=runtime_boundary,json=runtimeBoundary,proto3" json:"runtime_boundary,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The binding and immutable target that declared this record. + Declared *SolutionDeclaredBinding `protobuf:"bytes,10,opt,name=declared,proto3,oneof" json:"declared,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SolutionRegistration) Reset() { @@ -363,37 +392,39 @@ func (x *SolutionRegistration) GetTombstonedAt() *timestamppb.Timestamp { return nil } -func (x *SolutionRegistration) GetRuntimeBoundary() string { +func (x *SolutionRegistration) GetDeclared() *SolutionDeclaredBinding { if x != nil { - return x.RuntimeBoundary + return x.Declared } - return "" + return nil } -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -type SolutionFrontendRegistration struct { - state protoimpl.MessageState `protogen:"open.v1"` - Manifest string `protobuf:"bytes,1,opt,name=manifest,proto3" json:"manifest,omitempty"` - ContractVersion string `protobuf:"bytes,2,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// ListSolutionRegistrationsRequest reads the whole registry. There is one +// registry per deployment and it holds tens of records at most, so consumers +// rebuild their cache from a full snapshot rather than tailing a change feed. +type ListSolutionRegistrationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tombstones are omitted by default: a routing cache wants the live set. An + // operator diagnosing "was this deleted or never registered?" asks for them. + IncludeTombstoned bool `protobuf:"varint,1,opt,name=include_tombstoned,json=includeTombstoned,proto3" json:"include_tombstoned,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SolutionFrontendRegistration) Reset() { - *x = SolutionFrontendRegistration{} +func (x *ListSolutionRegistrationsRequest) Reset() { + *x = ListSolutionRegistrationsRequest{} mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SolutionFrontendRegistration) String() string { +func (x *ListSolutionRegistrationsRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SolutionFrontendRegistration) ProtoMessage() {} +func (*ListSolutionRegistrationsRequest) ProtoMessage() {} -func (x *SolutionFrontendRegistration) ProtoReflect() protoreflect.Message { +func (x *ListSolutionRegistrationsRequest) ProtoReflect() protoreflect.Message { mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -405,51 +436,42 @@ func (x *SolutionFrontendRegistration) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SolutionFrontendRegistration.ProtoReflect.Descriptor instead. -func (*SolutionFrontendRegistration) Descriptor() ([]byte, []int) { +// Deprecated: Use ListSolutionRegistrationsRequest.ProtoReflect.Descriptor instead. +func (*ListSolutionRegistrationsRequest) Descriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{3} } -func (x *SolutionFrontendRegistration) GetManifest() string { - if x != nil { - return x.Manifest - } - return "" -} - -func (x *SolutionFrontendRegistration) GetContractVersion() string { +func (x *ListSolutionRegistrationsRequest) GetIncludeTombstoned() bool { if x != nil { - return x.ContractVersion + return x.IncludeTombstoned } - return "" + return false } -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -type SolutionBackendRegistration struct { - state protoimpl.MessageState `protogen:"open.v1"` - Upstream string `protobuf:"bytes,1,opt,name=upstream,proto3" json:"upstream,omitempty"` - ServiceAlias string `protobuf:"bytes,2,opt,name=service_alias,json=serviceAlias,proto3" json:"service_alias,omitempty"` - ContractVersion string `protobuf:"bytes,3,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type ListSolutionRegistrationsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Registrations []*SolutionRegistration `protobuf:"bytes,1,rep,name=registrations,proto3" json:"registrations,omitempty"` + // Highest revision in the registry, tombstones included. Two consumers that + // report the same registry_revision have converged on the same state. + RegistryRevision int64 `protobuf:"varint,2,opt,name=registry_revision,json=registryRevision,proto3" json:"registry_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SolutionBackendRegistration) Reset() { - *x = SolutionBackendRegistration{} +func (x *ListSolutionRegistrationsResponse) Reset() { + *x = ListSolutionRegistrationsResponse{} mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SolutionBackendRegistration) String() string { +func (x *ListSolutionRegistrationsResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SolutionBackendRegistration) ProtoMessage() {} +func (*ListSolutionRegistrationsResponse) ProtoMessage() {} -func (x *SolutionBackendRegistration) ProtoReflect() protoreflect.Message { +func (x *ListSolutionRegistrationsResponse) ProtoReflect() protoreflect.Message { mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -461,75 +483,71 @@ func (x *SolutionBackendRegistration) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SolutionBackendRegistration.ProtoReflect.Descriptor instead. -func (*SolutionBackendRegistration) Descriptor() ([]byte, []int) { +// Deprecated: Use ListSolutionRegistrationsResponse.ProtoReflect.Descriptor instead. +func (*ListSolutionRegistrationsResponse) Descriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{4} } -func (x *SolutionBackendRegistration) GetUpstream() string { - if x != nil { - return x.Upstream - } - return "" -} - -func (x *SolutionBackendRegistration) GetServiceAlias() string { +func (x *ListSolutionRegistrationsResponse) GetRegistrations() []*SolutionRegistration { if x != nil { - return x.ServiceAlias + return x.Registrations } - return "" + return nil } -func (x *SolutionBackendRegistration) GetContractVersion() string { +func (x *ListSolutionRegistrationsResponse) GetRegistryRevision() int64 { if x != nil { - return x.ContractVersion + return x.RegistryRevision } - return "" + return 0 } -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -type PutSolutionRegistrationRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - SolutionId string `protobuf:"bytes,1,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` - Publisher string `protobuf:"bytes,2,opt,name=publisher,proto3" json:"publisher,omitempty"` - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - ExpectedRevision *int64 `protobuf:"varint,3,opt,name=expected_revision,json=expectedRevision,proto3,oneof" json:"expected_revision,omitempty"` - // Requested liveness window for this half. The registrant renews within it. - LeaseSeconds uint32 `protobuf:"varint,4,opt,name=lease_seconds,json=leaseSeconds,proto3" json:"lease_seconds,omitempty"` - // Types that are valid to be assigned to Half: +// SolutionDeclaredBinding identifies the applied declaration and target. +type SolutionDeclaredBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + // The generation of that binding the host applied into this record. + Generation uint64 `protobuf:"varint,2,opt,name=generation,proto3" json:"generation,omitempty"` + // publisher/name@version of the applied generation. + Release string `protobuf:"bytes,3,opt,name=release,proto3" json:"release,omitempty"` + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. // - // *PutSolutionRegistrationRequest_Frontend - // *PutSolutionRegistrationRequest_Backend - Half isPutSolutionRegistrationRequest_Half `protobuf_oneof:"half"` + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + TargetId string `protobuf:"bytes,4,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + Kind SolutionDeclaredKind `protobuf:"varint,5,opt,name=kind,proto3,enum=saas.accounts.v1.SolutionDeclaredKind" json:"kind,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *PutSolutionRegistrationRequest) Reset() { - *x = PutSolutionRegistrationRequest{} +func (x *SolutionDeclaredBinding) Reset() { + *x = SolutionDeclaredBinding{} mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *PutSolutionRegistrationRequest) String() string { +func (x *SolutionDeclaredBinding) String() string { return protoimpl.X.MessageStringOf(x) } -func (*PutSolutionRegistrationRequest) ProtoMessage() {} +func (*SolutionDeclaredBinding) ProtoMessage() {} -func (x *PutSolutionRegistrationRequest) ProtoReflect() protoreflect.Message { +func (x *SolutionDeclaredBinding) ProtoReflect() protoreflect.Message { mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -541,106 +559,153 @@ func (x *PutSolutionRegistrationRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use PutSolutionRegistrationRequest.ProtoReflect.Descriptor instead. -func (*PutSolutionRegistrationRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use SolutionDeclaredBinding.ProtoReflect.Descriptor instead. +func (*SolutionDeclaredBinding) Descriptor() ([]byte, []int) { return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{5} } -func (x *PutSolutionRegistrationRequest) GetSolutionId() string { +func (x *SolutionDeclaredBinding) GetBindingId() string { if x != nil { - return x.SolutionId + return x.BindingId } return "" } -func (x *PutSolutionRegistrationRequest) GetPublisher() string { +func (x *SolutionDeclaredBinding) GetGeneration() uint64 { if x != nil { - return x.Publisher - } - return "" -} - -func (x *PutSolutionRegistrationRequest) GetExpectedRevision() int64 { - if x != nil && x.ExpectedRevision != nil { - return *x.ExpectedRevision + return x.Generation } return 0 } -func (x *PutSolutionRegistrationRequest) GetLeaseSeconds() uint32 { +func (x *SolutionDeclaredBinding) GetRelease() string { if x != nil { - return x.LeaseSeconds + return x.Release } - return 0 + return "" } -func (x *PutSolutionRegistrationRequest) GetHalf() isPutSolutionRegistrationRequest_Half { +func (x *SolutionDeclaredBinding) GetTargetId() string { if x != nil { - return x.Half + return x.TargetId } - return nil + return "" } -func (x *PutSolutionRegistrationRequest) GetFrontend() *SolutionFrontendRegistration { +func (x *SolutionDeclaredBinding) GetKind() SolutionDeclaredKind { if x != nil { - if x, ok := x.Half.(*PutSolutionRegistrationRequest_Frontend); ok { - return x.Frontend - } + return x.Kind } - return nil + return SolutionDeclaredKind_SOLUTION_DECLARED_KIND_UNSPECIFIED +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +type SolutionHostBindingGeneration struct { + state protoimpl.MessageState `protogen:"open.v1"` + Generation uint64 `protobuf:"varint,1,opt,name=generation,proto3" json:"generation,omitempty"` + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + Digest string `protobuf:"bytes,2,opt,name=digest,proto3" json:"digest,omitempty"` + Document string `protobuf:"bytes,3,opt,name=document,proto3" json:"document,omitempty"` + At *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=at,proto3" json:"at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SolutionHostBindingGeneration) Reset() { + *x = SolutionHostBindingGeneration{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *PutSolutionRegistrationRequest) GetBackend() *SolutionBackendRegistration { +func (x *SolutionHostBindingGeneration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SolutionHostBindingGeneration) ProtoMessage() {} + +func (x *SolutionHostBindingGeneration) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] if x != nil { - if x, ok := x.Half.(*PutSolutionRegistrationRequest_Backend); ok { - return x.Backend + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) } + return ms } - return nil + return mi.MessageOf(x) } -type isPutSolutionRegistrationRequest_Half interface { - isPutSolutionRegistrationRequest_Half() +// Deprecated: Use SolutionHostBindingGeneration.ProtoReflect.Descriptor instead. +func (*SolutionHostBindingGeneration) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{6} } -type PutSolutionRegistrationRequest_Frontend struct { - Frontend *SolutionFrontendRegistration `protobuf:"bytes,5,opt,name=frontend,proto3,oneof"` +func (x *SolutionHostBindingGeneration) GetGeneration() uint64 { + if x != nil { + return x.Generation + } + return 0 } -type PutSolutionRegistrationRequest_Backend struct { - Backend *SolutionBackendRegistration `protobuf:"bytes,6,opt,name=backend,proto3,oneof"` +func (x *SolutionHostBindingGeneration) GetDigest() string { + if x != nil { + return x.Digest + } + return "" } -func (*PutSolutionRegistrationRequest_Frontend) isPutSolutionRegistrationRequest_Half() {} +func (x *SolutionHostBindingGeneration) GetDocument() string { + if x != nil { + return x.Document + } + return "" +} -func (*PutSolutionRegistrationRequest_Backend) isPutSolutionRegistrationRequest_Half() {} +func (x *SolutionHostBindingGeneration) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -type DeleteSolutionRegistrationRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - SolutionId string `protobuf:"bytes,1,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` - ExpectedRevision *int64 `protobuf:"varint,2,opt,name=expected_revision,json=expectedRevision,proto3,oneof" json:"expected_revision,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +type SolutionHostBindingAppliedGeneration struct { + state protoimpl.MessageState `protogen:"open.v1"` + Generation *SolutionHostBindingGeneration `protobuf:"bytes,1,opt,name=generation,proto3" json:"generation,omitempty"` + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + Removed bool `protobuf:"varint,2,opt,name=removed,proto3" json:"removed,omitempty"` + // The route aliases the applied generation holds. A tombstone holds none. + Routes []string `protobuf:"bytes,3,rep,name=routes,proto3" json:"routes,omitempty"` + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + SolutionId string `protobuf:"bytes,4,opt,name=solution_id,json=solutionId,proto3" json:"solution_id,omitempty"` + Release string `protobuf:"bytes,5,opt,name=release,proto3" json:"release,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *DeleteSolutionRegistrationRequest) Reset() { - *x = DeleteSolutionRegistrationRequest{} - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] +func (x *SolutionHostBindingAppliedGeneration) Reset() { + *x = SolutionHostBindingAppliedGeneration{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *DeleteSolutionRegistrationRequest) String() string { +func (x *SolutionHostBindingAppliedGeneration) String() string { return protoimpl.X.MessageStringOf(x) } -func (*DeleteSolutionRegistrationRequest) ProtoMessage() {} +func (*SolutionHostBindingAppliedGeneration) ProtoMessage() {} -func (x *DeleteSolutionRegistrationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[6] +func (x *SolutionHostBindingAppliedGeneration) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -651,52 +716,89 @@ func (x *DeleteSolutionRegistrationRequest) ProtoReflect() protoreflect.Message return mi.MessageOf(x) } -// Deprecated: Use DeleteSolutionRegistrationRequest.ProtoReflect.Descriptor instead. -func (*DeleteSolutionRegistrationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{6} +// Deprecated: Use SolutionHostBindingAppliedGeneration.ProtoReflect.Descriptor instead. +func (*SolutionHostBindingAppliedGeneration) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{7} +} + +func (x *SolutionHostBindingAppliedGeneration) GetGeneration() *SolutionHostBindingGeneration { + if x != nil { + return x.Generation + } + return nil } -func (x *DeleteSolutionRegistrationRequest) GetSolutionId() string { +func (x *SolutionHostBindingAppliedGeneration) GetRemoved() bool { + if x != nil { + return x.Removed + } + return false +} + +func (x *SolutionHostBindingAppliedGeneration) GetRoutes() []string { + if x != nil { + return x.Routes + } + return nil +} + +func (x *SolutionHostBindingAppliedGeneration) GetSolutionId() string { if x != nil { return x.SolutionId } return "" } -func (x *DeleteSolutionRegistrationRequest) GetExpectedRevision() int64 { - if x != nil && x.ExpectedRevision != nil { - return *x.ExpectedRevision +func (x *SolutionHostBindingAppliedGeneration) GetRelease() string { + if x != nil { + return x.Release } - return 0 + return "" } -// ListSolutionRegistrationsRequest reads the whole registry. There is one -// registry per deployment and it holds tens of records at most, so consumers -// rebuild their cache from a full snapshot rather than tailing a change feed. -type ListSolutionRegistrationsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Tombstones are omitted by default: a routing cache wants the live set. An - // operator diagnosing "was this deleted or never registered?" asks for them. - IncludeTombstoned bool `protobuf:"varint,1,opt,name=include_tombstoned,json=includeTombstoned,proto3" json:"include_tombstoned,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +type SolutionHostBindingState struct { + state protoimpl.MessageState `protogen:"open.v1"` + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + HostCoordinate string `protobuf:"bytes,2,opt,name=host_coordinate,json=hostCoordinate,proto3" json:"host_coordinate,omitempty"` + HostComponent string `protobuf:"bytes,3,opt,name=host_component,json=hostComponent,proto3" json:"host_component,omitempty"` + // The newest generation delivery has shown this host, whether or not it + // passed. + Desired *SolutionHostBindingGeneration `protobuf:"bytes,4,opt,name=desired,proto3,oneof" json:"desired,omitempty"` + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + Applied *SolutionHostBindingAppliedGeneration `protobuf:"bytes,5,opt,name=applied,proto3,oneof" json:"applied,omitempty"` + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + PendingGeneration uint64 `protobuf:"varint,6,opt,name=pending_generation,json=pendingGeneration,proto3" json:"pending_generation,omitempty"` + // Why the pending generation was not applied. Empty when nothing is pending. + PendingReason string `protobuf:"bytes,7,opt,name=pending_reason,json=pendingReason,proto3" json:"pending_reason,omitempty"` + PendingSince *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=pending_since,json=pendingSince,proto3,oneof" json:"pending_since,omitempty"` + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + Registration *SolutionRegistration `protobuf:"bytes,9,opt,name=registration,proto3,oneof" json:"registration,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *ListSolutionRegistrationsRequest) Reset() { - *x = ListSolutionRegistrationsRequest{} - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] +func (x *SolutionHostBindingState) Reset() { + *x = SolutionHostBindingState{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListSolutionRegistrationsRequest) String() string { +func (x *SolutionHostBindingState) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListSolutionRegistrationsRequest) ProtoMessage() {} +func (*SolutionHostBindingState) ProtoMessage() {} -func (x *ListSolutionRegistrationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[7] +func (x *SolutionHostBindingState) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -707,43 +809,105 @@ func (x *ListSolutionRegistrationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListSolutionRegistrationsRequest.ProtoReflect.Descriptor instead. -func (*ListSolutionRegistrationsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{7} +// Deprecated: Use SolutionHostBindingState.ProtoReflect.Descriptor instead. +func (*SolutionHostBindingState) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{8} } -func (x *ListSolutionRegistrationsRequest) GetIncludeTombstoned() bool { +func (x *SolutionHostBindingState) GetBindingId() string { if x != nil { - return x.IncludeTombstoned + return x.BindingId } - return false + return "" } -type ListSolutionRegistrationsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Registrations []*SolutionRegistration `protobuf:"bytes,1,rep,name=registrations,proto3" json:"registrations,omitempty"` - // Highest revision in the registry, tombstones included. Two consumers that - // report the same registry_revision have converged on the same state. - RegistryRevision int64 `protobuf:"varint,2,opt,name=registry_revision,json=registryRevision,proto3" json:"registry_revision,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *SolutionHostBindingState) GetHostCoordinate() string { + if x != nil { + return x.HostCoordinate + } + return "" } -func (x *ListSolutionRegistrationsResponse) Reset() { - *x = ListSolutionRegistrationsResponse{} - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] +func (x *SolutionHostBindingState) GetHostComponent() string { + if x != nil { + return x.HostComponent + } + return "" +} + +func (x *SolutionHostBindingState) GetDesired() *SolutionHostBindingGeneration { + if x != nil { + return x.Desired + } + return nil +} + +func (x *SolutionHostBindingState) GetApplied() *SolutionHostBindingAppliedGeneration { + if x != nil { + return x.Applied + } + return nil +} + +func (x *SolutionHostBindingState) GetPendingGeneration() uint64 { + if x != nil { + return x.PendingGeneration + } + return 0 +} + +func (x *SolutionHostBindingState) GetPendingReason() string { + if x != nil { + return x.PendingReason + } + return "" +} + +func (x *SolutionHostBindingState) GetPendingSince() *timestamppb.Timestamp { + if x != nil { + return x.PendingSince + } + return nil +} + +func (x *SolutionHostBindingState) GetRegistration() *SolutionRegistration { + if x != nil { + return x.Registration + } + return nil +} + +func (x *SolutionHostBindingState) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +type ListSolutionHostBindingsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionHostBindingsRequest) Reset() { + *x = ListSolutionHostBindingsRequest{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListSolutionRegistrationsResponse) String() string { +func (x *ListSolutionHostBindingsRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListSolutionRegistrationsResponse) ProtoMessage() {} +func (*ListSolutionHostBindingsRequest) ProtoMessage() {} -func (x *ListSolutionRegistrationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[8] +func (x *ListSolutionHostBindingsRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -754,41 +918,69 @@ func (x *ListSolutionRegistrationsResponse) ProtoReflect() protoreflect.Message return mi.MessageOf(x) } -// Deprecated: Use ListSolutionRegistrationsResponse.ProtoReflect.Descriptor instead. -func (*ListSolutionRegistrationsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{8} +// Deprecated: Use ListSolutionHostBindingsRequest.ProtoReflect.Descriptor instead. +func (*ListSolutionHostBindingsRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{9} } -func (x *ListSolutionRegistrationsResponse) GetRegistrations() []*SolutionRegistration { +type ListSolutionHostBindingsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Bindings []*SolutionHostBindingState `protobuf:"bytes,1,rep,name=bindings,proto3" json:"bindings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSolutionHostBindingsResponse) Reset() { + *x = ListSolutionHostBindingsResponse{} + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSolutionHostBindingsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSolutionHostBindingsResponse) ProtoMessage() {} + +func (x *ListSolutionHostBindingsResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_solution_registry_proto_msgTypes[10] if x != nil { - return x.Registrations + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return nil + return mi.MessageOf(x) } -func (x *ListSolutionRegistrationsResponse) GetRegistryRevision() int64 { +// Deprecated: Use ListSolutionHostBindingsResponse.ProtoReflect.Descriptor instead. +func (*ListSolutionHostBindingsResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_solution_registry_proto_rawDescGZIP(), []int{10} +} + +func (x *ListSolutionHostBindingsResponse) GetBindings() []*SolutionHostBindingState { if x != nil { - return x.RegistryRevision + return x.Bindings } - return 0 + return nil } var File_saas_accounts_v1_solution_registry_proto protoreflect.FileDescriptor const file_saas_accounts_v1_solution_registry_proto_rawDesc = "" + "\n" + - "(saas/accounts/v1/solution_registry.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc2\x01\n" + + "(saas/accounts/v1/solution_registry.proto\x12\x10saas.accounts.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x94\x01\n" + "\x17SolutionFrontendBinding\x12\x1a\n" + "\brevision\x18\x01 \x01(\x03R\brevision\x12\x1a\n" + "\bmanifest\x18\x02 \x01(\tR\bmanifest\x12)\n" + - "\x10contract_version\x18\x03 \x01(\tR\x0fcontractVersion\x12D\n" + - "\x10lease_expires_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0eleaseExpiresAt\"\xe6\x01\n" + + "\x10contract_version\x18\x03 \x01(\tR\x0fcontractVersionJ\x04\b\x04\x10\x05R\x10lease_expires_at\"\xb8\x01\n" + "\x16SolutionBackendBinding\x12\x1a\n" + "\brevision\x18\x01 \x01(\x03R\brevision\x12\x1a\n" + "\bupstream\x18\x02 \x01(\tR\bupstream\x12#\n" + "\rservice_alias\x18\x03 \x01(\tR\fserviceAlias\x12)\n" + - "\x10contract_version\x18\x04 \x01(\tR\x0fcontractVersion\x12D\n" + - "\x10lease_expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x0eleaseExpiresAt\"\xa3\x04\n" + + "\x10contract_version\x18\x04 \x01(\tR\x0fcontractVersionJ\x04\b\x05\x10\x06R\x10lease_expires_at\"\xe9\x04\n" + "\x14SolutionRegistration\x12\x1f\n" + "\vsolution_id\x18\x01 \x01(\tR\n" + "solutionId\x12\x1c\n" + @@ -799,51 +991,78 @@ const file_saas_accounts_v1_solution_registry_proto_rawDesc = "" + "\abackend\x18\x06 \x01(\v2(.saas.accounts.v1.SolutionBackendBindingH\x01R\abackend\x88\x01\x01\x129\n" + "\n" + "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12D\n" + - "\rtombstoned_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x02R\ftombstonedAt\x88\x01\x01\x12)\n" + - "\x10runtime_boundary\x18\t \x01(\tR\x0fruntimeBoundaryB\v\n" + + "\rtombstoned_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x02R\ftombstonedAt\x88\x01\x01\x12J\n" + + "\bdeclared\x18\n" + + " \x01(\v2).saas.accounts.v1.SolutionDeclaredBindingH\x03R\bdeclared\x88\x01\x01B\v\n" + "\t_frontendB\n" + "\n" + "\b_backendB\x10\n" + - "\x0e_tombstoned_at\"|\n" + - "\x1cSolutionFrontendRegistration\x12'\n" + - "\bmanifest\x18\x01 \x01(\tB\v\xbaH\br\x06\x10\x02\x18\x80\x80\x10R\bmanifest\x123\n" + - "\x10contract_version\x18\x02 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x01R\x0fcontractVersion\"\xae\x01\n" + - "\x1bSolutionBackendRegistration\x12)\n" + - "\bupstream\x18\x01 \x01(\tB\r\xbaH\n" + - "r\b\x10\x01\x18\x80\x10\x88\x01\x01R\bupstream\x12/\n" + - "\rservice_alias\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x01R\fserviceAlias\x123\n" + - "\x10contract_version\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x01R\x0fcontractVersion\"\xc5\x03\n" + - "\x1ePutSolutionRegistrationRequest\x12O\n" + - "\vsolution_id\x18\x01 \x01(\tB.\xbaH+r)\x10\x01\x18\x80\x012\"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$R\n" + - "solutionId\x12(\n" + - "\tpublisher\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x02R\tpublisher\x129\n" + - "\x11expected_revision\x18\x03 \x01(\x03B\a\xbaH\x04\"\x02 \x00H\x01R\x10expectedRevision\x88\x01\x01\x12/\n" + - "\rlease_seconds\x18\x04 \x01(\rB\n" + - "\xbaH\a*\x05\x18\x90\x1c(\x1eR\fleaseSeconds\x12L\n" + - "\bfrontend\x18\x05 \x01(\v2..saas.accounts.v1.SolutionFrontendRegistrationH\x00R\bfrontend\x12I\n" + - "\abackend\x18\x06 \x01(\v2-.saas.accounts.v1.SolutionBackendRegistrationH\x00R\abackendB\r\n" + - "\x04half\x12\x05\xbaH\x02\b\x01B\x14\n" + - "\x12_expected_revision\"\xa1\x01\n" + - "!DeleteSolutionRegistrationRequest\x12+\n" + - "\vsolution_id\x18\x01 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\x80\x01R\n" + - "solutionId\x129\n" + - "\x11expected_revision\x18\x02 \x01(\x03B\a\xbaH\x04\"\x02 \x00H\x00R\x10expectedRevision\x88\x01\x01B\x14\n" + - "\x12_expected_revision\"Q\n" + + "\x0e_tombstoned_atB\v\n" + + "\t_declaredJ\x04\b\t\x10\n" + + "R\x10runtime_boundary\"Q\n" + " ListSolutionRegistrationsRequest\x12-\n" + "\x12include_tombstoned\x18\x01 \x01(\bR\x11includeTombstoned\"\x9e\x01\n" + "!ListSolutionRegistrationsResponse\x12L\n" + "\rregistrations\x18\x01 \x03(\v2&.saas.accounts.v1.SolutionRegistrationR\rregistrations\x12+\n" + - "\x11registry_revision\x18\x02 \x01(\x03R\x10registryRevision*\xa3\x02\n" + + "\x11registry_revision\x18\x02 \x01(\x03R\x10registryRevision\"\xcb\x01\n" + + "\x17SolutionDeclaredBinding\x12\x1d\n" + + "\n" + + "binding_id\x18\x01 \x01(\tR\tbindingId\x12\x1e\n" + + "\n" + + "generation\x18\x02 \x01(\x04R\n" + + "generation\x12\x18\n" + + "\arelease\x18\x03 \x01(\tR\arelease\x12\x1b\n" + + "\ttarget_id\x18\x04 \x01(\tR\btargetId\x12:\n" + + "\x04kind\x18\x05 \x01(\x0e2&.saas.accounts.v1.SolutionDeclaredKindR\x04kind\"\x9f\x01\n" + + "\x1dSolutionHostBindingGeneration\x12\x1e\n" + + "\n" + + "generation\x18\x01 \x01(\x04R\n" + + "generation\x12\x16\n" + + "\x06digest\x18\x02 \x01(\tR\x06digest\x12\x1a\n" + + "\bdocument\x18\x03 \x01(\tR\bdocument\x12*\n" + + "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"\xe4\x01\n" + + "$SolutionHostBindingAppliedGeneration\x12O\n" + + "\n" + + "generation\x18\x01 \x01(\v2/.saas.accounts.v1.SolutionHostBindingGenerationR\n" + + "generation\x12\x18\n" + + "\aremoved\x18\x02 \x01(\bR\aremoved\x12\x16\n" + + "\x06routes\x18\x03 \x03(\tR\x06routes\x12\x1f\n" + + "\vsolution_id\x18\x04 \x01(\tR\n" + + "solutionId\x12\x18\n" + + "\arelease\x18\x05 \x01(\tR\arelease\"\x93\x05\n" + + "\x18SolutionHostBindingState\x12\x1d\n" + + "\n" + + "binding_id\x18\x01 \x01(\tR\tbindingId\x12'\n" + + "\x0fhost_coordinate\x18\x02 \x01(\tR\x0ehostCoordinate\x12%\n" + + "\x0ehost_component\x18\x03 \x01(\tR\rhostComponent\x12N\n" + + "\adesired\x18\x04 \x01(\v2/.saas.accounts.v1.SolutionHostBindingGenerationH\x00R\adesired\x88\x01\x01\x12U\n" + + "\aapplied\x18\x05 \x01(\v26.saas.accounts.v1.SolutionHostBindingAppliedGenerationH\x01R\aapplied\x88\x01\x01\x12-\n" + + "\x12pending_generation\x18\x06 \x01(\x04R\x11pendingGeneration\x12%\n" + + "\x0epending_reason\x18\a \x01(\tR\rpendingReason\x12D\n" + + "\rpending_since\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x02R\fpendingSince\x88\x01\x01\x12O\n" + + "\fregistration\x18\t \x01(\v2&.saas.accounts.v1.SolutionRegistrationH\x03R\fregistration\x88\x01\x01\x129\n" + + "\n" + + "updated_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\n" + + "\n" + + "\b_desiredB\n" + + "\n" + + "\b_appliedB\x10\n" + + "\x0e_pending_sinceB\x0f\n" + + "\r_registration\"!\n" + + "\x1fListSolutionHostBindingsRequest\"j\n" + + " ListSolutionHostBindingsResponse\x12F\n" + + "\bbindings\x18\x01 \x03(\v2*.saas.accounts.v1.SolutionHostBindingStateR\bbindings*\xa5\x02\n" + "\x1aSolutionRegistrationStatus\x12,\n" + "(SOLUTION_REGISTRATION_STATUS_UNSPECIFIED\x10\x00\x12'\n" + "#SOLUTION_REGISTRATION_STATUS_ACTIVE\x10\x01\x12(\n" + - "$SOLUTION_REGISTRATION_STATUS_PENDING\x10\x02\x12(\n" + - "$SOLUTION_REGISTRATION_STATUS_EXPIRED\x10\x03\x12-\n" + + "$SOLUTION_REGISTRATION_STATUS_PENDING\x10\x02\x12-\n" + ")SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE\x10\x04\x12+\n" + - "'SOLUTION_REGISTRATION_STATUS_TOMBSTONED\x10\x05B\xc1\x01\n" + + "'SOLUTION_REGISTRATION_STATUS_TOMBSTONED\x10\x05\"\x04\b\x03\x10\x03*$SOLUTION_REGISTRATION_STATUS_EXPIRED*\x86\x01\n" + + "\x14SolutionDeclaredKind\x12&\n" + + "\"SOLUTION_DECLARED_KIND_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fSOLUTION_DECLARED_KIND_SOLUTION\x10\x01\x12!\n" + + "\x1dSOLUTION_DECLARED_KIND_MODULE\x10\x02B\xc1\x01\n" + "\x14com.saas.accounts.v1B\x15SolutionRegistryProtoP\x01Z0auth-gateway/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" var ( @@ -858,37 +1077,46 @@ func file_saas_accounts_v1_solution_registry_proto_rawDescGZIP() []byte { return file_saas_accounts_v1_solution_registry_proto_rawDescData } -var file_saas_accounts_v1_solution_registry_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_saas_accounts_v1_solution_registry_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_saas_accounts_v1_solution_registry_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_saas_accounts_v1_solution_registry_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_saas_accounts_v1_solution_registry_proto_goTypes = []any{ - (SolutionRegistrationStatus)(0), // 0: saas.accounts.v1.SolutionRegistrationStatus - (*SolutionFrontendBinding)(nil), // 1: saas.accounts.v1.SolutionFrontendBinding - (*SolutionBackendBinding)(nil), // 2: saas.accounts.v1.SolutionBackendBinding - (*SolutionRegistration)(nil), // 3: saas.accounts.v1.SolutionRegistration - (*SolutionFrontendRegistration)(nil), // 4: saas.accounts.v1.SolutionFrontendRegistration - (*SolutionBackendRegistration)(nil), // 5: saas.accounts.v1.SolutionBackendRegistration - (*PutSolutionRegistrationRequest)(nil), // 6: saas.accounts.v1.PutSolutionRegistrationRequest - (*DeleteSolutionRegistrationRequest)(nil), // 7: saas.accounts.v1.DeleteSolutionRegistrationRequest - (*ListSolutionRegistrationsRequest)(nil), // 8: saas.accounts.v1.ListSolutionRegistrationsRequest - (*ListSolutionRegistrationsResponse)(nil), // 9: saas.accounts.v1.ListSolutionRegistrationsResponse - (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (SolutionRegistrationStatus)(0), // 0: saas.accounts.v1.SolutionRegistrationStatus + (SolutionDeclaredKind)(0), // 1: saas.accounts.v1.SolutionDeclaredKind + (*SolutionFrontendBinding)(nil), // 2: saas.accounts.v1.SolutionFrontendBinding + (*SolutionBackendBinding)(nil), // 3: saas.accounts.v1.SolutionBackendBinding + (*SolutionRegistration)(nil), // 4: saas.accounts.v1.SolutionRegistration + (*ListSolutionRegistrationsRequest)(nil), // 5: saas.accounts.v1.ListSolutionRegistrationsRequest + (*ListSolutionRegistrationsResponse)(nil), // 6: saas.accounts.v1.ListSolutionRegistrationsResponse + (*SolutionDeclaredBinding)(nil), // 7: saas.accounts.v1.SolutionDeclaredBinding + (*SolutionHostBindingGeneration)(nil), // 8: saas.accounts.v1.SolutionHostBindingGeneration + (*SolutionHostBindingAppliedGeneration)(nil), // 9: saas.accounts.v1.SolutionHostBindingAppliedGeneration + (*SolutionHostBindingState)(nil), // 10: saas.accounts.v1.SolutionHostBindingState + (*ListSolutionHostBindingsRequest)(nil), // 11: saas.accounts.v1.ListSolutionHostBindingsRequest + (*ListSolutionHostBindingsResponse)(nil), // 12: saas.accounts.v1.ListSolutionHostBindingsResponse + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp } var file_saas_accounts_v1_solution_registry_proto_depIdxs = []int32{ - 10, // 0: saas.accounts.v1.SolutionFrontendBinding.lease_expires_at:type_name -> google.protobuf.Timestamp - 10, // 1: saas.accounts.v1.SolutionBackendBinding.lease_expires_at:type_name -> google.protobuf.Timestamp - 0, // 2: saas.accounts.v1.SolutionRegistration.status:type_name -> saas.accounts.v1.SolutionRegistrationStatus - 1, // 3: saas.accounts.v1.SolutionRegistration.frontend:type_name -> saas.accounts.v1.SolutionFrontendBinding - 2, // 4: saas.accounts.v1.SolutionRegistration.backend:type_name -> saas.accounts.v1.SolutionBackendBinding - 10, // 5: saas.accounts.v1.SolutionRegistration.updated_at:type_name -> google.protobuf.Timestamp - 10, // 6: saas.accounts.v1.SolutionRegistration.tombstoned_at:type_name -> google.protobuf.Timestamp - 4, // 7: saas.accounts.v1.PutSolutionRegistrationRequest.frontend:type_name -> saas.accounts.v1.SolutionFrontendRegistration - 5, // 8: saas.accounts.v1.PutSolutionRegistrationRequest.backend:type_name -> saas.accounts.v1.SolutionBackendRegistration - 3, // 9: saas.accounts.v1.ListSolutionRegistrationsResponse.registrations:type_name -> saas.accounts.v1.SolutionRegistration - 10, // [10:10] is the sub-list for method output_type - 10, // [10:10] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 0, // 0: saas.accounts.v1.SolutionRegistration.status:type_name -> saas.accounts.v1.SolutionRegistrationStatus + 2, // 1: saas.accounts.v1.SolutionRegistration.frontend:type_name -> saas.accounts.v1.SolutionFrontendBinding + 3, // 2: saas.accounts.v1.SolutionRegistration.backend:type_name -> saas.accounts.v1.SolutionBackendBinding + 13, // 3: saas.accounts.v1.SolutionRegistration.updated_at:type_name -> google.protobuf.Timestamp + 13, // 4: saas.accounts.v1.SolutionRegistration.tombstoned_at:type_name -> google.protobuf.Timestamp + 7, // 5: saas.accounts.v1.SolutionRegistration.declared:type_name -> saas.accounts.v1.SolutionDeclaredBinding + 4, // 6: saas.accounts.v1.ListSolutionRegistrationsResponse.registrations:type_name -> saas.accounts.v1.SolutionRegistration + 1, // 7: saas.accounts.v1.SolutionDeclaredBinding.kind:type_name -> saas.accounts.v1.SolutionDeclaredKind + 13, // 8: saas.accounts.v1.SolutionHostBindingGeneration.at:type_name -> google.protobuf.Timestamp + 8, // 9: saas.accounts.v1.SolutionHostBindingAppliedGeneration.generation:type_name -> saas.accounts.v1.SolutionHostBindingGeneration + 8, // 10: saas.accounts.v1.SolutionHostBindingState.desired:type_name -> saas.accounts.v1.SolutionHostBindingGeneration + 9, // 11: saas.accounts.v1.SolutionHostBindingState.applied:type_name -> saas.accounts.v1.SolutionHostBindingAppliedGeneration + 13, // 12: saas.accounts.v1.SolutionHostBindingState.pending_since:type_name -> google.protobuf.Timestamp + 4, // 13: saas.accounts.v1.SolutionHostBindingState.registration:type_name -> saas.accounts.v1.SolutionRegistration + 13, // 14: saas.accounts.v1.SolutionHostBindingState.updated_at:type_name -> google.protobuf.Timestamp + 10, // 15: saas.accounts.v1.ListSolutionHostBindingsResponse.bindings:type_name -> saas.accounts.v1.SolutionHostBindingState + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_saas_accounts_v1_solution_registry_proto_init() } @@ -897,18 +1125,14 @@ func file_saas_accounts_v1_solution_registry_proto_init() { return } file_saas_accounts_v1_solution_registry_proto_msgTypes[2].OneofWrappers = []any{} - file_saas_accounts_v1_solution_registry_proto_msgTypes[5].OneofWrappers = []any{ - (*PutSolutionRegistrationRequest_Frontend)(nil), - (*PutSolutionRegistrationRequest_Backend)(nil), - } - file_saas_accounts_v1_solution_registry_proto_msgTypes[6].OneofWrappers = []any{} + file_saas_accounts_v1_solution_registry_proto_msgTypes[8].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_solution_registry_proto_rawDesc), len(file_saas_accounts_v1_solution_registry_proto_rawDesc)), - NumEnums: 1, - NumMessages: 9, + NumEnums: 2, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/module/services/auth-gateway/code/routing.go b/module/services/auth-gateway/code/routing.go index 659f8aac2..55744dbea 100644 --- a/module/services/auth-gateway/code/routing.go +++ b/module/services/auth-gateway/code/routing.go @@ -222,11 +222,16 @@ func (m *RouteMatcher) RequiredArtifactUpstreams() []routeArtifactUpstream { } // ReservedV1Prefixes returns the set of `/v1/` first segments owned by -// the loaded catalog (generated + explicit extensions). Runtime module -// federation must never register one of these prefixes: the catalog is the -// authority for its own surface, and a colliding registration would be a route -// the matcher can never reach (catalog wins) — so it is rejected at -// registration rather than silently ignored. +// the loaded catalog (generated + explicit extensions). +// +// A declared module whose route alias is one of these cannot be served: the +// matcher runs first and takes every path it matches, so the module would serve +// only the paths the catalog happens not to match — one prefix split between two +// authorities. handleDeclaredModule refuses such an alias rather than serving +// half of it. main used this set to refuse the claim when a module registered +// itself; with declaration there is no registration to refuse at this edge, so +// the refusal here is the backstop and what a delivery may declare is the +// upstream question. func (m *RouteMatcher) ReservedV1Prefixes() map[string]struct{} { reserved := make(map[string]struct{}) collect := func(path string) { @@ -245,37 +250,6 @@ func (m *RouteMatcher) ReservedV1Prefixes() map[string]struct{} { return reserved } -// v1Prefix extracts the `` from a `/v1//...` (or `/v1/`) -// path. It reports false for any path not under /v1/ or with an empty prefix. -// -// A custom-verb route ends its first segment with `:` -// (`/v1/permissions:check`, `/v1/work-contexts:renew`), and the verb is not part -// of the prefix. Splitting only on `/` made `permissions:check` the "prefix" of -// that route, which left the real prefix `permissions` absent from -// ReservedV1Prefixes: a runtime module could claim a namespace the catalog owns, -// which is exactly what the reservation exists to refuse. It also made the -// federation lookup on the other caller compare a verb-suffixed segment against -// registered prefixes, so `/v1/:` could never reach a registered -// module even when that module owned the prefix. -func v1Prefix(path string) (string, bool) { - const root = "/v1/" - if !strings.HasPrefix(path, root) { - return "", false - } - rest := path[len(root):] - prefix := rest - if idx := strings.IndexByte(rest, '/'); idx >= 0 { - prefix = rest[:idx] - } - if idx := strings.IndexByte(prefix, ':'); idx >= 0 { - prefix = prefix[:idx] - } - if prefix == "" { - return "", false - } - return prefix, true -} - // MatchREST looks up a REST route by HTTP method and path. func (m *RouteMatcher) MatchREST(method, path string) *RouteEntry { method = strings.ToUpper(method) diff --git a/module/services/auth-gateway/code/routing_catalog_gen.go b/module/services/auth-gateway/code/routing_catalog_gen.go index 8e8a3dba0..6cddddd29 100644 --- a/module/services/auth-gateway/code/routing_catalog_gen.go +++ b/module/services/auth-gateway/code/routing_catalog_gen.go @@ -76,6 +76,7 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/customers.GDPRService/RequestExport", UpstreamPath: "/saas.accounts.v1.GDPRService/RequestExport", Procedure: "/saas.accounts.v1.GDPRService/RequestExport"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.InstallationService/GetInstallation", UpstreamPath: "/saas.accounts.v1.InstallationService/GetInstallation", Procedure: "/saas.accounts.v1.InstallationService/GetInstallation"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.InstallationService/InstallSolution", UpstreamPath: "/saas.accounts.v1.InstallationService/InstallSolution", Procedure: "/saas.accounts.v1.InstallationService/InstallSolution"}, + {Service: "accounts_connect", Method: "POST", Path: "/customers.InstallationService/ListAvailableSolutions", UpstreamPath: "/saas.accounts.v1.InstallationService/ListAvailableSolutions", Procedure: "/saas.accounts.v1.InstallationService/ListAvailableSolutions"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.InstallationService/TransferInstallationOwnership", UpstreamPath: "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", Procedure: "/saas.accounts.v1.InstallationService/TransferInstallationOwnership"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.InstallationService/UninstallSolution", UpstreamPath: "/saas.accounts.v1.InstallationService/UninstallSolution", Procedure: "/saas.accounts.v1.InstallationService/UninstallSolution"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.IntrospectionService/GetServiceInfo", UpstreamPath: "/saas.accounts.v1.IntrospectionService/GetServiceInfo", Procedure: "/saas.accounts.v1.IntrospectionService/GetServiceInfo"}, @@ -273,6 +274,7 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.GDPRService/RequestExport", UpstreamPath: "", Procedure: "/saas.accounts.v1.GDPRService/RequestExport"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.InstallationService/GetInstallation", UpstreamPath: "", Procedure: "/saas.accounts.v1.InstallationService/GetInstallation"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.InstallationService/InstallSolution", UpstreamPath: "", Procedure: "/saas.accounts.v1.InstallationService/InstallSolution"}, + {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.InstallationService/ListAvailableSolutions", UpstreamPath: "", Procedure: "/saas.accounts.v1.InstallationService/ListAvailableSolutions"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", UpstreamPath: "", Procedure: "/saas.accounts.v1.InstallationService/TransferInstallationOwnership"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.InstallationService/UninstallSolution", UpstreamPath: "", Procedure: "/saas.accounts.v1.InstallationService/UninstallSolution"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.IntrospectionService/GetServiceInfo", UpstreamPath: "", Procedure: "/saas.accounts.v1.IntrospectionService/GetServiceInfo"}, diff --git a/module/services/auth-gateway/code/routing_rest_catalog_gen.go b/module/services/auth-gateway/code/routing_rest_catalog_gen.go index 8bf5e2084..938d57d12 100644 --- a/module/services/auth-gateway/code/routing_rest_catalog_gen.go +++ b/module/services/auth-gateway/code/routing_rest_catalog_gen.go @@ -39,6 +39,7 @@ func generatedCatalogRESTRoutes() []*RouteEntry { {Service: "accounts", Method: "GET", Path: "/v1/gdpr/delete/{id}", Procedure: "/saas.accounts.v1.GDPRService/GetDeletionStatus"}, {Service: "accounts", Method: "GET", Path: "/v1/gdpr/export/{id}", Procedure: "/saas.accounts.v1.GDPRService/GetExportStatus"}, {Service: "accounts", Method: "GET", Path: "/v1/installations/{installation_id}", Procedure: "/saas.accounts.v1.InstallationService/GetInstallation"}, + {Service: "accounts", Method: "GET", Path: "/v1/installations:listAvailable", Procedure: "/saas.accounts.v1.InstallationService/ListAvailableSolutions"}, {Service: "accounts", Method: "GET", Path: "/v1/invitations", Procedure: "/saas.accounts.v1.InvitationService/ListInvitations"}, {Service: "accounts", Method: "GET", Path: "/v1/mfa/devices", Procedure: "/saas.accounts.v1.MFAService/ListDevices"}, {Service: "accounts", Method: "GET", Path: "/v1/notifications", Procedure: "/saas.accounts.v1.NotificationService/ListNotifications"}, diff --git a/module/services/auth-gateway/code/routing_rest_test.go b/module/services/auth-gateway/code/routing_rest_test.go index 9ca18aa11..d23346c48 100644 --- a/module/services/auth-gateway/code/routing_rest_test.go +++ b/module/services/auth-gateway/code/routing_rest_test.go @@ -5,7 +5,6 @@ import ( "net/http" "os" "path/filepath" - "sort" "testing" "github.com/stretchr/testify/require" @@ -99,80 +98,6 @@ func TestGeneratedRESTSurfaceAndExtensions(t *testing.T) { require.False(t, matcher.MatchREST(http.MethodGet, "/v1/status").Protected) } -// Every `/v1/` the catalog owns is a prefix a runtime-registered module -// may never claim: handleModuleRegister refuses a colliding registration with -// 409, and because registrations live in the gateway process that refusal takes -// effect on the next restart of either side — a module that registered the -// prefix yesterday goes dark and cannot come back. That makes the reserved set a -// contract with every downstream composer, and it was being derived silently -// from whatever paths the catalog happened to contain: adding two GitHub webhook -// routes took `/v1/datasource` away from every composition with nothing, no -// test and no document, saying so. -// -// So the set is pinned. A new prefix here is not a lint failure to be silenced — -// it means the route you just added narrowed what composed modules may register. -// Either mount it under a prefix the catalog already owns, or add it here and to -// the reserved list in module/GATEWAY_ROUTES.md deliberately. -func TestReservedV1PrefixesArePinned(t *testing.T) { - generated, err := LoadRESTRoutesFromCatalog() - require.NoError(t, err) - extensions, err := LoadRESTExtensionsFromDir(context.Background(), DefaultRoutingDir()) - require.NoError(t, err) - connect, err := LoadConnectRoutesFromCatalog() - require.NoError(t, err) - - matcher := NewRouteMatcher(append(generated, extensions...), connect) - reserved := make([]string, 0, len(matcher.ReservedV1Prefixes())) - for prefix := range matcher.ReservedV1Prefixes() { - reserved = append(reserved, prefix) - } - sort.Strings(reserved) - - require.Equal(t, []string{ - ".well-known", - "accessible-scopes", - "acquisition", - "api-keys", - "approvals", - "audit-event-types", - "audit-log", - "auth", - "billing", - "collection-access", - "consent", - "datasource", - "delegations", - "directory", - "email", - "gdpr", - "installations", - "invitations", - "mfa", - "notifications", - "oauth2", - "organizations", - "platform", - "principals", - "public", - "record-shares", - "resource-follows", - "role-assignments", - "roles", - "scope-grants", - "scope-nodes", - "sso", - "status", - "subscriptions", - "teams", - "user", - "users", - "version", - "waitlist", - "webhooks", - "work-contexts", - }, reserved) -} - func TestRESTExtensionsRejectDescriptorRoutesAndDisabledEntries(t *testing.T) { tests := []struct { name string diff --git a/module/services/auth-gateway/code/work_context.go b/module/services/auth-gateway/code/work_context.go index f1d3c2586..a49ca56bc 100644 --- a/module/services/auth-gateway/code/work_context.go +++ b/module/services/auth-gateway/code/work_context.go @@ -57,10 +57,38 @@ func (v *workContextVerifier) Refresh(ctx context.Context) error { return v.cache.refresh(ctx) } -// Verify establishes trust for a presented Work Context. It confirms the -// signature, freshness, and attenuation of the token against the published -// keys; audience and scope are the callee's concern and are not asserted here. -func (v *workContextVerifier) Verify(ctx context.Context, token workcontext.WorkContextToken) error { +// gatewayAudience is the name a capability may NEVER be addressed to. +// +// This gateway is a forwarding hop: it inspects a presented capability and never +// consumes one. A token addressed to the hop itself is therefore a token nobody +// will ever consume — and worse, it is what a caller would mint to get the hop to +// treat a capability as its own rather than as something to forward. There is no +// legitimate producer of such a token, so it is refused by name rather than +// forwarded and left for a callee to puzzle over. +const gatewayAudience = "auth-gateway" + +// Verify establishes trust for a presented Work Context against the route it was +// presented TO. +// +// `expected` is the audience derived from the resolved route — `solution:` for a solution route, the module capability audience for a declared module +// route, and EMPTY for a catalog route, whose destination is this host's own +// service and therefore has no audience of its own (see gatewayAudience: the hop's +// name is never one). +// +// An empty `expected` means "do not compare", and that is sdk-go's own sentinel +// (`check.want != ""` in workcontext/work_context.go), not a choice made here. It +// is load-bearing that this is the ONLY place that relies on it and that the +// reliance is explicit: for the two surfaces where an audience IS derivable, a +// value is always passed, so a capability minted for one solution cannot be spent +// on another's route. +// +// What is checked here is the signature, the window and the audience. Scope and +// consumption stay the callee's: this hop never consumes a nonce, never reads +// seals, and never grants. +func (v *workContextVerifier) Verify( + ctx context.Context, token workcontext.WorkContextToken, expected string, +) error { keyID, err := workContextTokenKeyID(token) if err != nil { return err @@ -69,9 +97,17 @@ func (v *workContextVerifier) Verify(ctx context.Context, token workcontext.Work if err != nil { return err } - if _, err := verifier.Verify(token, workcontext.WorkContextExpectations{}); err != nil { + claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{Audience: expected}) + if err != nil { return invalidWorkContext(err) } + // Refused AFTER the signature check, deliberately: an unsigned token claiming + // the hop's audience is an invalid token, not a routing question, and answering + // the audience refusal first would tell an unauthenticated caller which + // audience the hop answers to. + if claims.GetAudience() == gatewayAudience { + return fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", workcontext.ErrWorkContextInvalid) + } return nil } diff --git a/module/services/auth-gateway/code/work_context_test.go b/module/services/auth-gateway/code/work_context_test.go index 5576c6ad1..1012b0823 100644 --- a/module/services/auth-gateway/code/work_context_test.go +++ b/module/services/auth-gateway/code/work_context_test.go @@ -84,7 +84,7 @@ func TestWorkContextVerifier_ValidTokenPasses(t *testing.T) { server := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) verifier := newWorkContextVerifier(server.URL) - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, nil)) + err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, nil), "solution:demo") require.NoError(t, err) } @@ -96,7 +96,7 @@ func TestWorkContextVerifier_ForgedSignatureFailsClosed(t *testing.T) { verifier := newWorkContextVerifier(server.URL) // Claims the published key id but is signed by a key the JWKS never lists. - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, attacker, nil)) + err := verifier.Verify(context.Background(), mintWorkContext(t, kid, attacker, nil), "solution:demo") require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) } @@ -107,7 +107,7 @@ func TestWorkContextVerifier_ExpiredTokenFailsClosed(t *testing.T) { past := func() time.Time { return time.Now().Add(-time.Hour) } verifier := newWorkContextVerifier(server.URL) - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, past)) + err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, past), "solution:demo") require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) } @@ -118,7 +118,7 @@ func TestWorkContextVerifier_JWKSUnreachableFailsClosed(t *testing.T) { server.Close() verifier := newWorkContextVerifier(url) - err := verifier.Verify(context.Background(), mintWorkContext(t, "key-1", priv, nil)) + err := verifier.Verify(context.Background(), mintWorkContext(t, "key-1", priv, nil), "solution:demo") // An upstream outage must read as the same invalid sentinel, not a distinct // transport error class that would leak the dependency being down. require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) @@ -133,7 +133,7 @@ func TestWorkContextVerifier_UnknownKeyIDDoesNotStampede(t *testing.T) { verifier := newWorkContextVerifier(server.URL) ctx := context.Background() for i := 0; i < 5; i++ { - err := verifier.Verify(ctx, mintWorkContext(t, "rotated-away", other, nil)) + err := verifier.Verify(ctx, mintWorkContext(t, "rotated-away", other, nil), "solution:demo") require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) } // One warm fetch plus at most one unknown-key probe within the cache window. @@ -155,13 +155,13 @@ func TestWorkContextVerifier_PicksUpRotatedKey(t *testing.T) { verifier := newWorkContextVerifier(server.URL) ctx := context.Background() - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-1", currentPriv, nil))) + require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-1", currentPriv, nil), "solution:demo")) // Rotate: publish key-2 alongside key-1. The cache is still fresh, so the // unknown-key probe — not a TTL refresh — must discover the new key. rotated := jwksDocument(map[string]ed25519.PublicKey{"key-1": current, "key-2": next}) document.Store(&rotated) - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-2", nextPriv, nil))) + require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-2", nextPriv, nil), "solution:demo")) } func TestWorkContextVerifier_ConcurrentColdRequestsShareOneFetch(t *testing.T) { @@ -191,7 +191,7 @@ func TestWorkContextVerifier_ConcurrentColdRequestsShareOneFetch(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - errs <- verifier.Verify(context.Background(), token) + errs <- verifier.Verify(context.Background(), token, "solution:demo") }() } wg.Wait() @@ -214,7 +214,7 @@ func TestWorkContextVerifier_RefreshWarmsKeys(t *testing.T) { // Keys are warm, so a valid token verifies from cache even once the // publisher is unreachable. server.Close() - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, nil)) + err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, nil), "solution:demo") require.NoError(t, err) } @@ -247,7 +247,7 @@ func TestWorkContextVerifier_MalformedJWKSFailsClosedAsInvalid(t *testing.T) { t.Run(name, func(t *testing.T) { server := jwksServer(t, document, nil) verifier := newWorkContextVerifier(server.URL) - err := verifier.Verify(context.Background(), mintWorkContext(t, "key-1", priv, nil)) + err := verifier.Verify(context.Background(), mintWorkContext(t, "key-1", priv, nil), "solution:demo") require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) }) } @@ -274,11 +274,11 @@ func TestWorkContextVerifier_UnknownKeyIDDoesNotBlockARotatedKey(t *testing.T) { verifier := newWorkContextVerifier(server.URL) advance := freezeClock(verifier.cache) ctx := context.Background() - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-1", currentPriv, nil))) + require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-1", currentPriv, nil), "solution:demo")) // One presented capability names a key id nobody publishes. _, attacker := mustEd25519(t) - require.ErrorIs(t, verifier.Verify(ctx, mintWorkContext(t, "rotated-away", attacker, nil)), + require.ErrorIs(t, verifier.Verify(ctx, mintWorkContext(t, "rotated-away", attacker, nil), "solution:demo"), workcontext.ErrWorkContextInvalid) // The rotation lands while the cache is still fresh. @@ -286,7 +286,7 @@ func TestWorkContextVerifier_UnknownKeyIDDoesNotBlockARotatedKey(t *testing.T) { document.Store(&rotated) advance(jwksProbeInterval) - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-2", nextPriv, nil)), + require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-2", nextPriv, nil), "solution:demo"), "an unrecognised key id must not deny the probe to a genuinely rotated-in key") } diff --git a/module/services/auth-gateway/proto/saas/accounts/v1/solution_entitlements.proto b/module/services/auth-gateway/proto/saas/accounts/v1/solution_entitlements.proto new file mode 100644 index 000000000..dc048f8ee --- /dev/null +++ b/module/services/auth-gateway/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// ============================================================================ +// Per-viewer solution entitlement (issue #949) +// +// A viewer should see the solutions their organization installed and their teams +// were granted, and nothing else. Answering that needs two authority facts read +// together: what the organization has installed (installations) and which of +// those installations' authority-root scope nodes the viewer may act on (the +// grant + share union every scope decision resolves through). +// +// They are answered by ONE read, in one org transaction, rather than by joining +// two RPCs in a caller: read separately, a revoke landing between the two calls +// yields a menu entry whose grant is already gone, and the revision a consumer +// caches on would describe no state that ever existed. +// +// These messages live in their own file with deliberately minimal imports — the +// same reason solution_registry.proto does — so the auth-gateway can generate +// the same types from the same source proto without vendoring the policy +// options. The service that serves them is +// solution_entitlements_service.proto, which accounts alone needs. +// ============================================================================ + +// SolutionEntitlement is one installed solution a subject may use. It is +// deliberately a projection of the installation, not the installation: a +// consumer of this message renders a menu, and the owner of record, the +// succession set and the agent principal are no part of that. +message SolutionEntitlement { + // Was `solution_identifier`: the route alias, which consumers joined against + // their registered set. An alias is reusable by a later binding, so that join + // admitted a replacement to the predecessor's entitlement. Replaced by + // target_id below. + reserved 1; + reserved "solution_identifier"; + string installation_id = 2 [(buf.validate.field).string.uuid = true]; + // The installation's authority-root scope node — the node the viewer's grant + // had to reach for this entitlement to exist. Carried so a consumer can say + // which boundary admitted a solution without a second lookup. + string root_scope_node_id = 3 [(buf.validate.field).string.uuid = true]; + // Whether the installation is healthy right now, resolved live from the same + // authority facts GetInstallation reports. + // + // A bool rather than a copy of InstallationHealth on purpose. A consumer of + // this message shows an unhealthy solution as unavailable and never renders + // the reason — an operator reads that from GetInstallation — so restating that + // enum here would be a second copy of a value set to keep in lockstep. This + // reduces it instead, by the one rule that survives the enum growing: anything + // that is not HEALTHY is not healthy. + bool healthy = 4; + // The immutable solution target this entitlement is for — the key a consumer + // joins against its own registered set. + // + // A consumer resolves a route alias to a target through the host's applied + // presence state and then compares THIS id. That is what makes the alias-reuse + // transfer inexpressible: a replacement binding claiming a withdrawn alias + // resolves to its own target, which no installation of the predecessor names. + // + // The alias is deliberately absent. Carrying it here would let a consumer go + // back to joining on it, and it would be a copy of a value the presence + // snapshot already owns and may move. + string target_id = 5 [(buf.validate.field).string.uuid = true]; + // Deliberately no platform-administrator basis field. The scope union admits a + // platform super_admin for the action `read` alone, and using a solution is not + // a read, so that branch can never contribute an entitlement here — a field + // reporting it would be permanently false. A super_admin sees an organization's + // installations through GetInstallation / ListInstallations, not by appearing to + // be entitled to them. +} + +message ListSolutionEntitlementsRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + // The subject whose entitlements to enumerate. A principal: a team's + // entitlements reach a member through the team grant, which the underlying + // union already resolves, so asking about a principal answers "everything this + // person may use" including what a team confers. + string subject_id = 2 [(buf.validate.field).string.uuid = true]; + // Page bound. 0 -> server default; capped so an organization with many + // installations can never return an unbounded result in one call. + int32 page_size = 3 [(buf.validate.field).int32 = { + gte: 0 + lte: 500 + }]; + // Opaque cursor from a previous response's next_page_token; empty for page one. + string page_token = 4; +} + +message ListSolutionEntitlementsResponse { + repeated SolutionEntitlement entitlements = 1; + // Set when more entitlements remain; pass it back as page_token. Empty on the + // last page. + string next_page_token = 2; +} + +// Deliberately NOT here: a revision or fingerprint field. A consumer that caches +// a projection built from these rows keys it on a digest of the rows it actually +// received, which is exact by construction. A revision computed server-side would +// have to describe either one page or a set larger than the caller read, and the +// paginated case makes the two disagree. The ordering is deterministic (keyset on +// installation id), so a consumer's own digest is stable across reads that return +// the same entitlements. diff --git a/module/services/auth-gateway/proto/saas/accounts/v1/solution_registry.proto b/module/services/auth-gateway/proto/saas/accounts/v1/solution_registry.proto index 3cc97e45b..08933023d 100644 --- a/module/services/auth-gateway/proto/saas/accounts/v1/solution_registry.proto +++ b/module/services/auth-gateway/proto/saas/accounts/v1/solution_registry.proto @@ -2,65 +2,47 @@ syntax = "proto3"; package saas.accounts.v1; -import "buf/validate/validate.proto"; import "google/protobuf/timestamp.proto"; -// ============================================================================ -// Durable solution registry (issue #534) -// -// A solution is an independently deployed component the host has no build-time -// knowledge of. It self-registers two halves at runtime: a frontend half (the -// Module-Federation manifest the host renders and navigates to) and a backend -// half (the upstream the gateway proxies /solutions//* to). Both used to be -// process-local maps, so a restart lost them, a registration behind a load -// balancer reached one replica only, and one half could be live while the other -// was missing. -// -// These messages live in their own file, with deliberately minimal imports, so -// the auth-gateway can generate the same types from the same source proto and -// call SolutionRegistryService (declared in solution_registry_service.proto) -// over the internal listener. Sharing the generated definition rather than -// restating the wire shape is what keeps the two sides from drifting. -// -// Two facts are deliberately distinct. Durable installation intent is the -// record existing and not being tombstoned; ephemeral endpoint liveness is the -// per-half lease, renewed by whoever registered that half. A lapsed lease -// suspends routing without discarding the registration, and the derived status -// says which of the two is at fault. -// ============================================================================ +// Read-only projection of the host's declared solution presence. The gateway +// shares these messages and reads the accounts internal listener. -// SolutionRegistrationStatus is derived at read time from the stored record; it -// is never a stored column. It is what lets an operator tell a registration -// that was never completed from one whose publisher stopped renewing, and both -// from one that was deliberately removed. +// SolutionRegistrationStatus is derived from declared state and complete, +// compatible frontend/backend observations, never from an expiring lease. enum SolutionRegistrationStatus { SOLUTION_REGISTRATION_STATUS_UNSPECIFIED = 0; - // Both halves are present, their leases are live, and their declared contract + // Both halves are present and their declared contract // versions agree. This is the only status either surface serves. SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; - // A half has never registered. The record is durable but incomplete, so no + // A half has not been observed. The record is durable but incomplete, so no // page advertises it and the gateway has nothing to route to. SOLUTION_REGISTRATION_STATUS_PENDING = 2; - // Both halves registered, but at least one lease lapsed: the deployment that - // owns it stopped renewing. The intent survives; the endpoint is not live. - SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; + // Was `SOLUTION_REGISTRATION_STATUS_EXPIRED`: both halves registered but a + // lease had lapsed. Presence is delivered, never heartbeat-asserted, so there + // is no lease to lapse and no status to derive from one. Reserved rather than + // reused: an old reader decoding 3 would read "expired" out of whatever + // meaning a later value gave the number. + reserved 3; + reserved "SOLUTION_REGISTRATION_STATUS_EXPIRED"; // The two halves declare different contract versions, so activating them // together would serve a page against a backend it was not built for. SOLUTION_REGISTRATION_STATUS_INCOMPATIBLE = 4; - // The registration was deregistered. The row survives as a tombstone so a - // delayed heartbeat from a retiring deployment cannot recreate it. + // An applied removal keeps the declaration as a tombstone. SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; } // SolutionFrontendBinding is the stored frontend half. manifest is the document -// the frontend validated before handing it over; this service persists it -// verbatim and never reinterprets it, so the host keeps sole ownership of what -// a manifest may contain. +// the frontend validates while reading the projection; accounts stores it +// verbatim and keeps presence authority separate from runtime compatibility. message SolutionFrontendBinding { int64 revision = 1; string manifest = 2; string contract_version = 3; - google.protobuf.Timestamp lease_expires_at = 4; + // Was `google.protobuf.Timestamp lease_expires_at`: the renewal path is + // deleted, so a column nothing renews was legacy. Reserved, not reused — a + // released number never takes another name or type. + reserved 4; + reserved "lease_expires_at"; } // SolutionBackendBinding is the stored backend half: the upstream the gateway @@ -70,7 +52,10 @@ message SolutionBackendBinding { string upstream = 2; string service_alias = 3; string contract_version = 4; - google.protobuf.Timestamp lease_expires_at = 5; + // Was `google.protobuf.Timestamp lease_expires_at`: see + // SolutionFrontendBinding above. Reserved, not reused. + reserved 5; + reserved "lease_expires_at"; } // SolutionRegistration is the canonical record. revision is drawn from one @@ -78,8 +63,7 @@ message SolutionBackendBinding { // consumer can order two snapshots without a wall clock. message SolutionRegistration { string solution_id = 1; - // Owner of record. First claim binds it; a registration naming a different - // publisher is refused rather than overwriting. + // Canonical publisher attribution of the declared record. string publisher = 2; int64 revision = 3; SolutionRegistrationStatus status = 4; @@ -87,91 +71,20 @@ message SolutionRegistration { optional SolutionBackendBinding backend = 6; google.protobuf.Timestamp updated_at = 7; optional google.protobuf.Timestamp tombstoned_at = 8; - // The seed a solution's runtime boundary is derived from (issue #1015). + // Was `string runtime_boundary`: the opaque id every Work Context minted for + // this solution is sealed under. It is NOT a field a readable record carries + // — a boundary returned on a readable record is a capability leaking as a + // label — so it is deleted from the wire rather than renamed, and the host + // seals it into the capability instead. // - // ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - // assigned by this service when the record is created and never again, and it - // is the only input to the per-organization boundary every Work Context - // minted for that solution is sealed under — so it is what stands between one - // solution's runs and another's. No registrant needs it: accounts derives and - // seals the boundary from the credential the solution already presents. - // - // The field is kept rather than removed so this promise is testable: a change - // that starts populating it fails a test instead of shipping. - string runtime_boundary = 9; -} - -// SolutionFrontendRegistration is the caller-supplied frontend half. Revision -// and lease are assigned by the server, never proposed by the registrant. -message SolutionFrontendRegistration { - string manifest = 1 [(buf.validate.field).string = { - min_len: 2 - max_len: 262144 - }]; - string contract_version = 2 [(buf.validate.field).string.max_len = 128]; -} - -// SolutionBackendRegistration is the caller-supplied backend half. The gateway -// has already constrained the upstream to a permitted host before it gets here; -// this service stores what it is told and does not re-derive routing policy. -message SolutionBackendRegistration { - string upstream = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 2048 - uri: true - }]; - string service_alias = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - string contract_version = 3 [(buf.validate.field).string.max_len = 128]; -} - -// PutSolutionRegistrationRequest writes exactly one half of one record. -// -// Writing a half whose content is byte-identical to what is stored is a lease -// renewal: it refreshes liveness and deliberately does not advance the -// revision, so a heartbeat never looks like a change to a consumer. Writing -// different content to a half that already exists requires expected_revision, -// which must equal the record's current revision — that is how a publisher -// still holding an older view is stopped from overwriting newer state, and how -// two concurrent updates serialize instead of racing. -message PutSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - pattern: "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$" - }]; - string publisher = 2 [(buf.validate.field).string = { - min_len: 1 - max_len: 256 - }]; - // Compare-and-swap guard against the record's current revision. Required to - // change an existing half, and the only way to re-register a tombstoned - // record: the caller must name the tombstone's own revision, which a delayed - // retry from a retired deployment does not hold. - optional int64 expected_revision = 3 [(buf.validate.field).int64.gt = 0]; - // Requested liveness window for this half. The registrant renews within it. - uint32 lease_seconds = 4 [(buf.validate.field).uint32 = { - gte: 30 - lte: 3600 - }]; - oneof half { - option (buf.validate.oneof).required = true; - SolutionFrontendRegistration frontend = 5; - SolutionBackendRegistration backend = 6; - } -} - -// DeleteSolutionRegistrationRequest tombstones a record: both halves are -// cleared in the same statement that marks it deleted, so a reader that somehow -// ignored the tombstone still has no endpoint to route to. -message DeleteSolutionRegistrationRequest { - string solution_id = 1 [(buf.validate.field).string = { - min_len: 1 - max_len: 128 - }]; - optional int64 expected_revision = 2 [(buf.validate.field).int64.gt = 0]; + // Reserved rather than reused. A released field number never takes another + // name or type: a consumer still on the old schema reading 9 would decode a + // message where it expected a string. `declared` therefore took 10, which + // cost a digest move and is the price of not corrupting an old reader. + reserved 9; + reserved "runtime_boundary"; + // The binding and immutable target that declared this record. + optional SolutionDeclaredBinding declared = 10; } // ListSolutionRegistrationsRequest reads the whole registry. There is one @@ -189,3 +102,127 @@ message ListSolutionRegistrationsResponse { // report the same registry_revision have converged on the same state. int64 registry_revision = 2; } + +// ============================================================================ +// Declared solution presence (issue #952) +// +// A SolutionHostBinding is the document delivery hands this host: which solution +// runs here, at which generation. The host reconciles it into the registration +// record above. These messages are the read surface, and they keep the three +// facts apart on purpose — an operator's first question is which of them is +// wrong. +// ============================================================================ + +// SolutionDeclaredKind is what a declaration declares the presence of. +// +// Core requires it on every presence document and refuses any other value, so +// every admitted declaration has exactly one of the two. The host used to drop +// it on reconcile, which left the registry unable to say whether a record was a +// solution or a module — and the two are routed on different surfaces with +// different admission, so a host that cannot tell them apart must either serve +// both from one surface or serve neither. +// +// UNSPECIFIED is not a third kind and is never a default. It is what a reader +// decodes from a writer that did not set the field, and both routing surfaces +// refuse it by name rather than picking the kind that happens to be more +// permissive. +enum SolutionDeclaredKind { + SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + // A composed solution instance, routed at /solutions//* with + // per-viewer installation admission. + SOLUTION_DECLARED_KIND_SOLUTION = 1; + // One module instance, routed at /v1//* with the ordinary + // authenticated pipeline. + SOLUTION_DECLARED_KIND_MODULE = 2; +} + +// SolutionDeclaredBinding identifies the applied declaration and target. +message SolutionDeclaredBinding { + // Stable ID of the deployment instance that declared this record. It is not + // the solution id: a binding ID identifies one instance and may carry + // characters a path segment may not. + string binding_id = 1; + // The generation of that binding the host applied into this record. + uint64 generation = 2; + // publisher/name@version of the applied generation. + string release = 3; + // The immutable solution target this declaration opened — the identity an + // installation names. It is carried on the registration record so a consumer + // that was asked about a ROUTE ALIAS can resolve it to the target that + // currently serves it and compare identities. + // + // That resolution is the whole admission mechanism: a replacement binding + // which claimed a withdrawn alias resolves to its own target, which no + // installation of its predecessor names. A record with no declaration carries + // no target and is therefore admissible to nobody, which is the fail-closed + // answer and the direction of the cutover. + string target_id = 4; + // What the declaration declared the presence of. It decides which routing + // surface serves this record, so it is carried rather than inferred: a host + // that read the kind off the shape of a record — a publisher prefix, an alias + // convention — would be guessing at exactly the question that separates a + // surface with per-viewer admission from one without. + SolutionDeclaredKind kind = 5; +} + +// SolutionHostBindingGeneration is one generation of one document. The document +// is carried whole, canonically encoded, because "what is this host running" and +// "what has it been asked to run" are both answers an operator needs in full. +message SolutionHostBindingGeneration { + uint64 generation = 1; + // The canonical digest of the document, which is how a rewritten generation is + // told from a re-read of the applied one. + string digest = 2; + string document = 3; + google.protobuf.Timestamp at = 4; +} + +// SolutionHostBindingAppliedGeneration is the generation this host reconciled, +// and what it reconciled it into. +message SolutionHostBindingAppliedGeneration { + SolutionHostBindingGeneration generation = 1; + // A tombstone generation: the binding is declared absent. It is not an + // absence — the generation stays on the record, so a late or replayed older + // generation is still refused after a removal. + bool removed = 2; + // The route aliases the applied generation holds. A tombstone holds none. + repeated string routes = 3; + // The registration record this generation reconciled into. A tombstone keeps + // the key it withdrew, which is the only way it knows what to withdraw. + string solution_id = 4; + string release = 5; +} + +// SolutionHostBindingState is one binding's whole state: desired, applied, and +// why they differ. Endpoint and manifest observations live on +// SolutionRegistration, separate from the declaration that grants presence. +message SolutionHostBindingState { + string binding_id = 1; + string host_coordinate = 2; + string host_component = 3; + // The newest generation delivery has shown this host, whether or not it + // passed. + optional SolutionHostBindingGeneration desired = 4; + // The generation this host applied. Absent means nothing has ever passed, so + // nothing about this binding is being served. + optional SolutionHostBindingAppliedGeneration applied = 5; + // The generation delivery is showing that this host has not applied, or 0 when + // desired and applied agree. + uint64 pending_generation = 6; + // Why the pending generation was not applied. Empty when nothing is pending. + string pending_reason = 7; + optional google.protobuf.Timestamp pending_since = 8; + // The registration record this binding declares, when it has applied one. It + // carries endpoint and manifest observations and their derived status. + optional SolutionRegistration registration = 9; + google.protobuf.Timestamp updated_at = 10; +} + +// ListSolutionHostBindingsRequest reads every declared binding. There is one set +// per deployment and it holds tens of bindings at most, so a consumer rebuilds +// its view from a full snapshot. +message ListSolutionHostBindingsRequest {} + +message ListSolutionHostBindingsResponse { + repeated SolutionHostBindingState bindings = 1; +} diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index ddfe1c587..ae4e05e2c 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -38,10 +38,8 @@ workspace-configuration-dependencies: endpoints: - name: grpc visibility: internal - allow-modules: ["*"] - name: rest visibility: internal - allow-modules: ["*"] spec: hot-reload: true protocol-output-dirs: diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index d242bd11d..00759fb49 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -3,63 +3,35 @@ The authenticated product, behind auth-gateway. Architecture is in `FRONTEND_ARCHITECTURE.md` at the repository root; the page and plugin inventory in [../../FRONTEND_CATALOG.md](../../FRONTEND_CATALOG.md) and [../../FRONTEND_PLUGINS.md](../../FRONTEND_PLUGINS.md). This file covers the -runtime-registration surfaces and the published client kit. - -## Registering a solution's frontend half - -`POST /api/solutions/register` (and `DELETE ?id=…`) at -`code/src/app/api/solutions/register/route.ts`. The POST body is the solution -manifest — `id`, `nav`, `frontend.manifestUrl` + `exposedModule`, optional -`backend.serviceAlias`, optional compatibility requirements — validated in -`src/solutions/registry.ts`, which then writes the frontend half **through the -gateway** (`POST /solutions/_frontend`). - -`sources` is the optional declaration of what a solution is **built on**: -`[{ provider: github, repo: owner/name, paths, ref, label }]`. `repo` is held -to `AddGitHubSourceRequest`'s own pattern, and the paths and ref to its bounds, -at the far end of the same journey — the declaration is submitted unedited by -the kit's ``, so a repository the connect RPC would refuse -is refused when it is *declared* rather than weeks later when somebody presses -Connect. A malformed declaration fails the registration whole, as a dashboard -or a surface does, and one repository declared twice is refused outright: -nothing downstream could say which of the two a card renders. It is a -statement, not an authority — declaring a source connects nothing and grants -no read of its contents. The solution page reads it in process and hands it to -the remote it mounts as `SolutionBinding.declaredSources`, so it is on neither -the public nor the internal HTTP projection and no new RPC exists to serve it. - -The route **relays the registry's own answer**: `409` for a revision conflict, -`403` when the id belongs to another publisher, `422` (`registration_rejected`) -when the registry will not admit the manifest, `503` when the registry cannot be -reached — and `503` (not `401`) when the key set it verifies the credential -against cannot be reached, because a credential that was never judged was not -refused. A registrant is never told it is serving when it is not. - -A dashboard event carrying `fields` declares an audit event type the solution -owns (`packages/saas-plugin-manifest/src/data-graph.ts`). `assertDataGraph` -checks its shape here; accounts admits it into the audit registry in the same -write as the frontend half, and refuses the write — the `422` above, with a -`detail` naming the rule — when the namespace is not bound to the solution or -belongs to another producer, or a changed field set drops or retypes an -admitted field. accounts marks that refusal with a `google.rpc.ErrorInfo` -reason (`SOLUTION_AUDIT_DECLARATION_REJECTED`), and only that reason becomes -the gateway's `422 registration_rejected`; any other refusal keeps its old -mapping. Re-registering -a deregistered solution requires an explicit `reactivate: true`. The frontend -additionally enforces the declared runtime compatibility requirements before -activating a remote. +solution read projections and the published client kit. + +## Reading declared solutions + +`src/solutions/registry.ts` validates the gateway's durable registry snapshot and +caches it for five seconds. It exposes reads only. An expired snapshot is dropped +when a refresh fails, so callers receive unavailable instead of stale authority. +The manifest includes navigation, frontend loading information, backend alias, +compatibility requirements and optional dashboard, surfaces and source metadata. +These declarations grant no access to content. The solution page reads the full +manifest in process; browser projections carry only the fields described below. ## Three projections, deliberately separate -- `GET /api/solutions/register` is **unauthenticated** and returns exactly the - public navigation projection — `{id, nav}` per solution and nothing else. It is - what the sidebar polls, and it answers `503` — **never an empty list** — when - this replica cannot read the registry. +Two of them are **per viewer** (issue #949): they answer the solutions the +caller's organization installed and the caller's teams were granted, not the +deployment-wide registered set. + +- `GET /api/solutions` is **authenticated** and returns the navigation + projection — `{id, nav, available}` per entitled solution and nothing else. It is + what the sidebar polls (through `authedFetch`, so a lapsed access token is + exchanged and retried rather than emptying the menu). It answers `401` to an + unauthenticated caller and `503` when the registry or the authority cannot be + read — **never an empty list** for either. - `GET /api/solutions/surfaces?client=` - (`src/app/api/solutions/surfaces/route.ts`) is the same class of public - projection for a client that is **not** this host's web app: per solution, its - `id`, its title, its `origin`, and the declared `surfaces` whose `client` - matches. The kind is required and must be slug-shaped — absent is `400`, + (`src/app/api/solutions/surfaces/route.ts`) is the same class of projection for a + client that is **not** this host's web app: per entitled solution, its `id`, its + title, its `origin`, whether it is `available`, and the declared `surfaces` whose + `client` matches. The kind is required and must be slug-shaped — absent is `400`, malformed is `400`, never the unfiltered set and never a misleading empty list — and an unreadable registry is again `503`. The host does not enumerate client kinds: which ones exist is deployment configuration (the @@ -69,6 +41,51 @@ activating a remote. origin, so withholding it would not keep the origin from anyone who can use a surface — it would only make the answer unusable. The manifest path and the backend service are still withheld. + +**Where the identity comes from, and why not from here.** Neither route may derive +the organization. `src/lib/auth-session.ts` can read an `org` claim, but +`decodeJWTPayload` only base64-decodes — it verifies nothing — so a route that +narrowed on it would let a caller read another tenant's menu by editing one field. +These `/api/*` paths are also not proxied through the gateway (`src/proxy.ts` +forwards `/v1/*` and `/saas.accounts.v1.*` only), so no `ext_authz` stamp reaches +them either. `src/solutions/entitlements.ts` therefore forwards the caller's +credential to `GET /solutions/_entitlements` on the gateway, which authenticates +it, projects the organization and viewer from its own check, and answers from +accounts. `module/tools/solution_registration_boundary_test.go` holds both halves: +the projections must consult that read, and must not name the local decoders. + +**`available` is a client contract.** An installed, granted solution whose +installation is unhealthy stays listed with `available: false` — the grant exists, +so hiding it would send someone looking for one that already does. It is a new +field on both projections: the host's own sidebar renders such a solution disabled, +and **a registered client of the surfaces projection must honour it the same way** +— a client that ignores the field will offer an unavailable solution's surfaces as +usable. The solution proxy also checks per-viewer installation admission. `/s/{id}` +reads deployment-wide state; see SOLUTION_REGISTRATION.md §4 for this boundary. + +**Failure answers.** Neither projection answers an empty list for a failure. +`401` — the viewer is not signed in; `403 no_organization` — signed in with no +organization; `403 forbidden` — the gateway refused the viewer's credential; +`429 rate_limited` — the organization spent its read budget (the gateway meters the +entitlement read as a StandardRead per organization); `503` — the registry or the +authority could not be read, **including** when the gateway refuses this frontend's +own cluster-internal token. That last case is a deployment fault and is never +relayed as `401`: the gateway names its own refusals in +`X-Codefly-Entitlement-Refusal`, so a server credential problem cannot tell every +user to sign in again. + +**What is cached, and what is not.** The entitlement answer is read from the +authority on **every** request; it is never reused. Reusing it on a revision that +grant writes advance would be wrong, because the answer changes with no grant write +at all — a grant's `expires_at` passes, a member leaves a team, a role loses a +permission, an owner of record is demoted. What is memoized is only the shaping of +manifests into a projection, keyed on organization, viewer, a digest of the +entitlement answer just read, client kind and registry revision, so it can never +describe an answer other than the one this request received. + +The narrowing itself lives in `src/solutions/projections.ts`, apart from +`src/solutions/registry.ts`: the registry also feeds `findSolution`, which decides +whether `/s/{id}` renders, and it stays installation-blind. - Everything else a manifest carries (`frontend`, `backend`) is deployment topology, served instead by `GET /api/internal/solutions` (`src/app/api/internal/solutions/route.ts`), gated on the cluster-internal diff --git a/module/services/frontend/code/next.config.mjs b/module/services/frontend/code/next.config.mjs index 0a5fa6c78..f0f491a67 100644 --- a/module/services/frontend/code/next.config.mjs +++ b/module/services/frontend/code/next.config.mjs @@ -51,18 +51,6 @@ const legalDevPlaceholder = const nextConfig = { output: "standalone", reactCompiler: true, - // The dev server prints a line per request. Solutions re-register here on a - // heartbeat (and browsers poll the nav list from the same path), so that line - // was one per solution every few seconds — most of what a local run printed. - // The route logs the registration's state changes itself (registered, a new - // revision, refused and why, recovered) and counts the beats between them; - // see src/solutions/registration-log.ts. A production server prints no - // per-request lines, so this only quiets `next dev`. - logging: { - incomingRequests: { - ignore: [/^\/api\/solutions\/register(?:[?#]|$)/], - }, - }, // Inlined into client + server bundles at build time; read by legal-config.ts // to decide whether dev legal placeholders apply. Default derives from the // fixture boundary above, so real deploys stay safe-by-default. diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 0fb59fc06..1f5e9e8ea 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:54cb486f38f377a71fec1a6bbfc8f05fcb86a00054dd00cf1dd7b76a38aaac29", + "digest": "sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c", "services": [ { "name": "APIKeyService", @@ -173,6 +173,8 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListAvailableSolutions", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -375,13 +377,19 @@ "/saas.accounts.v1.SSOAdminService/StartSetup" ] }, + { + "name": "SolutionEntitlementService", + "fullName": "saas.accounts.v1.SolutionEntitlementService", + "procedures": [ + "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" + ] + }, { "name": "SolutionRegistryService", "fullName": "saas.accounts.v1.SolutionRegistryService", "procedures": [ - "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", - "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", - "/saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration" + "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", + "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations" ] }, { diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index e60ea95f0..043cef0e7 100644 Binary files a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb and b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb differ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index c94091576..bf895304c 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:54cb486f38f377a71fec1a6bbfc8f05fcb86a00054dd00cf1dd7b76a38aaac29 + contract-digest: sha256:5d7abfdea9eb48867b86146b977e8b34f4f4306249aade05ef189eaa00a4d46c services: - APIKeyService - AccessibleScopeService @@ -41,6 +41,7 @@ sources: - PrincipalService - ResourceFollowService - SSOAdminService + - SolutionEntitlementService - SolutionRegistryService - TeamService - UsageService @@ -51,6 +52,6 @@ sources: - WorkContextService as: accounts generated-by: - codefly: 0.1.178 + codefly: 0.1.179 companion: ghcr.io/codefly-dev/proto:0.0.16 facade: true diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts index 90a31cc56..a8a96d8a6 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts @@ -15,7 +15,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/installations.proto. */ export const file_saas_accounts_v1_installations: GenFile = /*@__PURE__*/ - fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEilwMKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEhsKE3NvbHV0aW9uX2lkZW50aWZpZXIYBCABKAkSJAoSb3duZXJfcHJpbmNpcGFsX2lkGAUgASgJQgi6SAVyA7ABARIeChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAYgAygJEiQKEnJvb3Rfc2NvcGVfbm9kZV9pZBgHIAEoCUIIukgFcgOwAQESNAoGc3RhdHVzGAggASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdGF0dXMSLgoKY3JlYXRlZF9hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoKcmV2b2tlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBAUINCgtfcmV2b2tlZF9hdCKmAwoWSW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiQKEGFnZW50X2lkZW50aWZpZXIYAiABKAlCCrpIB3IFEAEYgAQSJwoTc29sdXRpb25faWRlbnRpZmllchgDIAEoCUIKukgHcgUQARiABBIeCgxkaXNwbGF5X25hbWUYBCABKAlCCLpIBXIDGIAEEiIKEHJvb3Rfc2NvcGVfbGFiZWwYBiABKAlCCLpIBXIDGIAEEhkKB3JvbGVfaWQYByABKAlCCLpIBXIDsAEBEicKEm93bmVyX3ByaW5jaXBhbF9pZBgIIAEoCUILukgI2AEBcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgJIAMoCUIPukgMkgEJECAiBXIDsAEBEioKEWFsbG93ZWRfYXVkaWVuY2VzGAogAygJQg+6SAySAQkQQCIFcgMYgAQSJwoOYWxsb3dlZF9zY29wZXMYCyADKAlCD7pIDJIBCRBAIgVyAxiAAUoECAUQBlIPcm9vdF9zY29wZV9wYXRoIlcKGFVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEivgEKJFRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESKAoWbmV3X293bmVyX3ByaW5jaXBhbF9pZBgDIAEoCUIIukgFcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgEIAMoCUIPukgMkgEJECAiBXIDsAEBIlUKFkdldEluc3RhbGxhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBIoUBChdHZXRJbnN0YWxsYXRpb25SZXNwb25zZRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCp6ChJJbnN0YWxsYXRpb25TdGF0dXMSIwofSU5TVEFMTEFUSU9OX1NUQVRVU19VTlNQRUNJRklFRBAAEh4KGklOU1RBTExBVElPTl9TVEFUVVNfQUNUSVZFEAESHwobSU5TVEFMTEFUSU9OX1NUQVRVU19SRVZPS0VEEAIqhAIKEkluc3RhbGxhdGlvbkhlYWx0aBIjCh9JTlNUQUxMQVRJT05fSEVBTFRIX1VOU1BFQ0lGSUVEEAASHwobSU5TVEFMTEFUSU9OX0hFQUxUSF9IRUFMVEhZEAESKQolSU5TVEFMTEFUSU9OX0hFQUxUSF9OT19FTElHSUJMRV9PV05FUhACEiUKIUlOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfUkVWT0tFRBADEiYKIklOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfRElTQUJMRUQQBBIuCipJTlNUQUxMQVRJT05fSEVBTFRIX1NUQU5ESU5HX0dSQU5UX01JU1NJTkcQBTLvBgoTSW5zdGFsbGF0aW9uU2VydmljZRK6AQoPSW5zdGFsbFNvbHV0aW9uEiguc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsU29sdXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24iXcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5jcmVhdGVkEAJAAUgEUANYA2ABgtPkkwIWOgEqIhEvdjEvaW5zdGFsbGF0aW9ucxLSAQoRVW5pbnN0YWxsU29sdXRpb24SKi5zYWFzLmFjY291bnRzLnYxLlVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSJ5wvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLnJldm9rZWQQAkABSARQA1gDYAGC0+STAjI6ASoiLS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9OnVuaW5zdGFsbBKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfWIGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEi1wMKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEiQKEm93bmVyX3ByaW5jaXBhbF9pZBgFIAEoCUIIukgFcgOwAQESHgoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgGIAMoCRIkChJyb290X3Njb3BlX25vZGVfaWQYByABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgIIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjMKCnJldm9rZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESHgoJdGFyZ2V0X2lkGAsgASgJQgu6SAjYAQFyA7ABARIgCg5yZXZva2VkX3JlYXNvbhgMIAEoCUIIukgFcgMYgARCDQoLX3Jldm9rZWRfYXRKBAgEEAVSE3NvbHV0aW9uX2lkZW50aWZpZXIitQMKFkluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIkChBhZ2VudF9pZGVudGlmaWVyGAIgASgJQgq6SAdyBRABGIAEEh4KDGRpc3BsYXlfbmFtZRgEIAEoCUIIukgFcgMYgAQSIgoQcm9vdF9zY29wZV9sYWJlbBgGIAEoCUIIukgFcgMYgAQSGQoHcm9sZV9pZBgHIAEoCUIIukgFcgOwAQESJwoSb3duZXJfcHJpbmNpcGFsX2lkGAggASgJQgu6SAjYAQFyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAkgAygJQg+6SAySAQkQICIFcgOwAQESKgoRYWxsb3dlZF9hdWRpZW5jZXMYCiADKAlCD7pIDJIBCRBAIgVyAxiABBInCg5hbGxvd2VkX3Njb3BlcxgLIAMoCUIPukgMkgEJEEAiBXIDGIABEhsKCXRhcmdldF9pZBgMIAEoCUIIukgFcgOwAQFKBAgDEARKBAgFEAZSE3NvbHV0aW9uX2lkZW50aWZpZXJSD3Jvb3Rfc2NvcGVfcGF0aCJXChhVbmluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBIr4BCiRUcmFuc2Zlckluc3RhbGxhdGlvbk93bmVyc2hpcFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEigKFm5ld19vd25lcl9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEi8KFmNvX293bmVyX3ByaW5jaXBhbF9pZHMYBCADKAlCD7pIDJIBCRAgIgVyA7ABASJVChZHZXRJbnN0YWxsYXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASKFAQoXR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2USNAoMaW5zdGFsbGF0aW9uGAEgASgLMh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24SNAoGaGVhbHRoGAIgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25IZWFsdGginQEKGExpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEh0KCXBhZ2Vfc2l6ZRgDIAEoBUIKukgHGgUY9AMoABISCgpwYWdlX3Rva2VuGAQgASgJIoEBChNJbnN0YWxsYXRpb25TdW1tYXJ5EjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoInIKGUxpc3RJbnN0YWxsYXRpb25zUmVzcG9uc2USPAoNaW5zdGFsbGF0aW9ucxgBIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3VtbWFyeRIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAki7QEKEUF2YWlsYWJsZVNvbHV0aW9uEhsKCXRhcmdldF9pZBgBIAEoCUIIukgFcgOwAQESEgoKYmluZGluZ19pZBgCIAEoCRITCgtyb3V0ZV9hbGlhcxgDIAEoCRIZChFyZWxlYXNlX3B1Ymxpc2hlchgEIAEoCRIUCgxyZWxlYXNlX25hbWUYBSABKAkSFwoPcmVsZWFzZV92ZXJzaW9uGAYgASgJEhkKEW9wZW5lZF9nZW5lcmF0aW9uGAcgASgEEhoKEmFwcGxpZWRfZ2VuZXJhdGlvbhgIIAEoBBIRCglpbnN0YWxsZWQYCSABKAgibAodTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSJxCh5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVzcG9uc2USNgoJc29sdXRpb25zGAEgAygLMiMuc2Fhcy5hY2NvdW50cy52MS5BdmFpbGFibGVTb2x1dGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkqegoSSW5zdGFsbGF0aW9uU3RhdHVzEiMKH0lOU1RBTExBVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABIeChpJTlNUQUxMQVRJT05fU1RBVFVTX0FDVElWRRABEh8KG0lOU1RBTExBVElPTl9TVEFUVVNfUkVWT0tFRBACKoQCChJJbnN0YWxsYXRpb25IZWFsdGgSIwofSU5TVEFMTEFUSU9OX0hFQUxUSF9VTlNQRUNJRklFRBAAEh8KG0lOU1RBTExBVElPTl9IRUFMVEhfSEVBTFRIWRABEikKJUlOU1RBTExBVElPTl9IRUFMVEhfTk9fRUxJR0lCTEVfT1dORVIQAhIlCiFJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX1JFVk9LRUQQAxImCiJJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX0RJU0FCTEVEEAQSLgoqSU5TVEFMTEFUSU9OX0hFQUxUSF9TVEFORElOR19HUkFOVF9NSVNTSU5HEAUyxQkKE0luc3RhbGxhdGlvblNlcnZpY2USugEKD0luc3RhbGxTb2x1dGlvbhIoLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbFNvbHV0aW9uUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIl3C8xg9CAIQBCoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy5pbnN0YWxsYXRpb24uY3JlYXRlZBACQAFIBFADWANgAYLT5JMCFjoBKiIRL3YxL2luc3RhbGxhdGlvbnMS0gEKEVVuaW5zdGFsbFNvbHV0aW9uEiouc2Fhcy5hY2NvdW50cy52MS5Vbmluc3RhbGxTb2x1dGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiecLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5yZXZva2VkEAJAAUgEUANYA2ABgtPkkwIyOgEqIi0vdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfTp1bmluc3RhbGwSiQIKHVRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwEjYuc2Fhcy5hY2NvdW50cy52MS5UcmFuc2Zlckluc3RhbGxhdGlvbk93bmVyc2hpcFJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiKPAcLzGEsIAhAEKgwKBm9yZ19pZBACGAEwATorCidzYWFzLmluc3RhbGxhdGlvbi5vd25lcnNoaXBfdHJhbnNmZXJyZWQQAkABSARQA1gDYAGC0+STAjo6ASoiNS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9OnRyYW5zZmVyT3duZXJzaGlwErkBCg9HZXRJbnN0YWxsYXRpb24SKC5zYWFzLmFjY291bnRzLnYxLkdldEluc3RhbGxhdGlvblJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLkdldEluc3RhbGxhdGlvblJlc3BvbnNlIlHC8xgiCAIQAyoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCJRIjL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH0ShgEKEUxpc3RJbnN0YWxsYXRpb25zEiouc2Fhcy5hY2NvdW50cy52MS5MaXN0SW5zdGFsbGF0aW9uc1JlcXVlc3QaKy5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLKAQoWTGlzdEF2YWlsYWJsZVNvbHV0aW9ucxIvLnNhYXMuYWNjb3VudHMudjEuTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkxpc3RBdmFpbGFibGVTb2x1dGlvbnNSZXNwb25zZSJNwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQA1gDYAGC0+STAiESHy92MS9pbnN0YWxsYXRpb25zOmxpc3RBdmFpbGFibGViBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); /** * Installation is one solution's bound identity in an org. Server-assigned ids; @@ -39,11 +39,6 @@ export type Installation = Message<"saas.accounts.v1.Installation"> & { */ agentPrincipalId: string; - /** - * @generated from field: string solution_identifier = 4; - */ - solutionIdentifier: string; - /** * @generated from field: string owner_principal_id = 5; */ @@ -73,6 +68,36 @@ export type Installation = Message<"saas.accounts.v1.Installation"> & { * @generated from field: optional google.protobuf.Timestamp revoked_at = 10; */ revokedAt?: Timestamp; + + /** + * The immutable solution target this installation was consented to: one + * continuous period of one binding's presence on this host. It is never + * reused, so a replacement binding — even one claiming the same route alias — + * is a different target and inherits nothing. + * + * Deliberately NOT accompanied by the route alias. The alias is a property of + * the target that a later generation may move, and resolving it here would + * mean joining the host's presence state into a request-path listing — which + * would require granting request traffic read over every organisation's + * presence to answer about one. A consumer that needs the alias asks + * ListAvailableSolutions, which serves accepted applied state, and joins on + * this id. + * + * Empty only on a revoked historical row whose target could not be + * established at the cutover; an active installation always names one. + * + * @generated from field: string target_id = 11; + */ + targetId: string; + + /** + * Why a revoked installation was revoked, when it was not an administrator + * uninstalling: the installation-identity cutover, or a tenancy narrowing that + * withdrew this organisation. Empty on an active row. + * + * @generated from field: string revoked_reason = 12; + */ + revokedReason: string; }; /** @@ -99,11 +124,6 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe */ agentIdentifier: string; - /** - * @generated from field: string solution_identifier = 3; - */ - solutionIdentifier: string; - /** * Human display name for the agent principal. Empty defaults to the identifier. * @@ -157,6 +177,19 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe * @generated from field: repeated string allowed_scopes = 11; */ allowedScopes: string[]; + + /** + * The solution target being installed, from ListAvailableSolutions. Required. + * + * An installer chooses an identity, never an address. The target must be LIVE + * and ACCEPTED when the install lands: installing a withdrawn target would + * record consent to a presence that already ended, and installing a target + * whose newest generation was refused would record consent to a release this + * host never admitted. + * + * @generated from field: string target_id = 12; + */ + targetId: string; }; /** @@ -270,6 +303,235 @@ export type GetInstallationResponse = Message<"saas.accounts.v1.GetInstallationR export const GetInstallationResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_installations, 5); +/** + * ListInstallationsRequest asks what one organization has installed. Every other + * RPC on this service names a single installation, so until this existed nothing + * could answer "what has this org installed?" — which is the authority read a + * per-organization solution projection is built on (issue #949). + * + * @generated from message saas.accounts.v1.ListInstallationsRequest + */ +export type ListInstallationsRequest = Message<"saas.accounts.v1.ListInstallationsRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * Restrict to one lifecycle status. UNSPECIFIED returns every status, so a + * caller that must see only live installs asks for ACTIVE explicitly rather + * than leaning on a default that would silently widen if the enum grew. + * + * @generated from field: saas.accounts.v1.InstallationStatus status = 2; + */ + status: InstallationStatus; + + /** + * Page bound. 0 -> server default; capped so an organization with many + * installations can never return an unbounded result in one call. + * + * @generated from field: int32 page_size = 3; + */ + pageSize: number; + + /** + * Opaque cursor from a previous response's next_page_token; empty for page one. + * + * @generated from field: string page_token = 4; + */ + pageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListInstallationsRequest. + * Use `create(ListInstallationsRequestSchema)` to create a new message. + */ +export const ListInstallationsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 6); + +/** + * InstallationSummary is one row of the listing: the installation, plus the health + * resolved live beside it exactly as GetInstallation resolves it for one. Health + * travels with the row because a consumer that renders an installed solution as + * unavailable would otherwise need one GetInstallation per installation. + * + * @generated from message saas.accounts.v1.InstallationSummary + */ +export type InstallationSummary = Message<"saas.accounts.v1.InstallationSummary"> & { + /** + * @generated from field: saas.accounts.v1.Installation installation = 1; + */ + installation?: Installation; + + /** + * @generated from field: saas.accounts.v1.InstallationHealth health = 2; + */ + health: InstallationHealth; +}; + +/** + * Describes the message saas.accounts.v1.InstallationSummary. + * Use `create(InstallationSummarySchema)` to create a new message. + */ +export const InstallationSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 7); + +/** + * @generated from message saas.accounts.v1.ListInstallationsResponse + */ +export type ListInstallationsResponse = Message<"saas.accounts.v1.ListInstallationsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.InstallationSummary installations = 1; + */ + installations: InstallationSummary[]; + + /** + * Set when more installations remain; pass it back as page_token. Empty on the + * last page. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListInstallationsResponse. + * Use `create(ListInstallationsResponseSchema)` to create a new message. + */ +export const ListInstallationsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 8); + +/** + * AvailableSolution is one installable solution target: its immutable identity, + * the release the host currently has applied for it, and where it is routed. + * + * @generated from message saas.accounts.v1.AvailableSolution + */ +export type AvailableSolution = Message<"saas.accounts.v1.AvailableSolution"> & { + /** + * The immutable target identity an install names. Never reused. + * + * @generated from field: string target_id = 1; + */ + targetId: string; + + /** + * The binding whose presence this target records. Carried so an operator can + * correlate a catalogue row with delivery; an installation does not join on it, + * because one binding may have several targets over time. + * + * @generated from field: string binding_id = 2; + */ + bindingId: string; + + /** + * The route alias the applied generation claims — `/solutions//*`. A + * later generation may move it, so it is never an identity. + * + * @generated from field: string route_alias = 3; + */ + routeAlias: string; + + /** + * The applied release: publisher, name, version. + * + * @generated from field: string release_publisher = 4; + */ + releasePublisher: string; + + /** + * @generated from field: string release_name = 5; + */ + releaseName: string; + + /** + * @generated from field: string release_version = 6; + */ + releaseVersion: string; + + /** + * The generation that opened this target, and the newest applied generation. + * Equal until a later generation is applied for the same presence. + * + * @generated from field: uint64 opened_generation = 7; + */ + openedGeneration: bigint; + + /** + * @generated from field: uint64 applied_generation = 8; + */ + appliedGeneration: bigint; + + /** + * True when this organization already has an ACTIVE installation of this + * target. A catalogue shows it as installed rather than offering it twice; the + * unique index over (org_id, target_id) would refuse the second install anyway, + * and an administrator should not have to discover that by being refused. + * + * @generated from field: bool installed = 9; + */ + installed: boolean; +}; + +/** + * Describes the message saas.accounts.v1.AvailableSolution. + * Use `create(AvailableSolutionSchema)` to create a new message. + */ +export const AvailableSolutionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 9); + +/** + * @generated from message saas.accounts.v1.ListAvailableSolutionsRequest + */ +export type ListAvailableSolutionsRequest = Message<"saas.accounts.v1.ListAvailableSolutionsRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * Page bound. 0 -> server default; capped for the same reason the installation + * listing is capped. + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListAvailableSolutionsRequest. + * Use `create(ListAvailableSolutionsRequestSchema)` to create a new message. + */ +export const ListAvailableSolutionsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 10); + +/** + * @generated from message saas.accounts.v1.ListAvailableSolutionsResponse + */ +export type ListAvailableSolutionsResponse = Message<"saas.accounts.v1.ListAvailableSolutionsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.AvailableSolution solutions = 1; + */ + solutions: AvailableSolution[]; + + /** + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListAvailableSolutionsResponse. + * Use `create(ListAvailableSolutionsResponseSchema)` to create a new message. + */ +export const ListAvailableSolutionsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 11); + /** * InstallationStatus is the lifecycle of an installation row. An uninstall is a * soft delete (status=revoked, revoked_at set) so the agent, grants, and audit @@ -407,5 +669,45 @@ export const InstallationService: GenService<{ input: typeof GetInstallationRequestSchema; output: typeof GetInstallationResponseSchema; }, + /** + * ListInstallations enumerates one organization's installations with their live + * health. It is internal-tier on purpose: it takes the organization as a request + * field, so only a caller holding the cluster-internal credential may ask, and + * the tenant it asks about is the one the auth-gateway projected from a verified + * identity — never one a browser supplied. It sits beside + * PermissionService.ListAccessibleScopes for that reason: the two are read + * together to answer what a viewer's organization installed and what that viewer + * was granted. + * + * @generated from rpc saas.accounts.v1.InstallationService.ListInstallations + */ + listInstallations: { + methodKind: "unary"; + input: typeof ListInstallationsRequestSchema; + output: typeof ListInstallationsResponseSchema; + }, + /** + * ListAvailableSolutions is the catalogue: the solution targets an + * administrator may install right now. + * + * It answers from ACCEPTED applied state — a live target whose binding's + * newest APPLIED generation is a present one — and deliberately not from the + * diagnostic ListSolutionHostBindings, which also reports desired generations + * that were refused. The distinction is the point: a refused generation is + * something an operator must see and something an administrator must not be + * able to consent to, because consenting to a release this host never admitted + * records authority over a presence that does not exist. + * + * It carries the route alias, which the installation record deliberately does + * not: here the alias is a display and routing fact read from the target in + * the same statement, not an identity anything joins on. + * + * @generated from rpc saas.accounts.v1.InstallationService.ListAvailableSolutions + */ + listAvailableSolutions: { + methodKind: "unary"; + input: typeof ListAvailableSolutionsRequestSchema; + output: typeof ListAvailableSolutionsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_saas_accounts_v1_installations, 0); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts index 4c5584c5b..ee30d498b 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts @@ -22,7 +22,7 @@ import type { JsonObject, Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/module_capabilities.proto. */ export const file_saas_accounts_v1_module_capabilities: GenFile = /*@__PURE__*/ - fileDesc("CipzYWFzL2FjY291bnRzL3YxL21vZHVsZV9jYXBhYmlsaXRpZXMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoXTW9kdWxlRW5xdWV1ZUpvYlJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIpCgNqb2IYAiABKAsyFC5zYWFzLmpvYnMudjEuTmV3Sm9iQga6SAPIAQEidgoYTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlEhgKBmpvYl9pZBgBIAEoCUIIukgFcgOwAQESQAoLZGlzcG9zaXRpb24YAiABKA4yIy5zYWFzLmpvYnMudjEuSm9iRW5xdWV1ZURpc3Bvc2l0aW9uQga6SAPIAQEiygEKFk1vZHVsZUNsYWltSm9ic1JlcXVlc3QSLgoFcXVldWUYASABKAlCH7pIHHIaEAEYgAEyE15bYS16XVthLXowLTlfLi1dKiQSHQoJd29ya2VyX2lkGAIgASgJQgq6SAdyBRABGP8BEhgKBWxpbWl0GAMgASgNQgm6SAYqBBhkKAESRwoObGVhc2VfZHVyYXRpb24YBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IkIKF01vZHVsZUNsYWltSm9ic1Jlc3BvbnNlEicKBGpvYnMYASADKAsyGS5zYWFzLmpvYnMudjEuSm9iRW52ZWxvcGUilwEKGU1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARJCCglleHRlbnNpb24YAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IksKGk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlEi0KBWxlYXNlGAEgASgLMhYuc2Fhcy5qb2JzLnYxLkpvYkxlYXNlQga6SAPIAQEipgEKE01vZHVsZUFja0pvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARI3Cg5leGVjdXRpb25fa2luZBgCIAEoCUIfukgcchoYQDIWXiR8XlthLXpdW2EtejAtOV8uLV0qJBIeCgxleGVjdXRpb25faWQYAyABKAlCCLpIBXIDGP8BIsIBChRNb2R1bGVOYWNrSm9iUmVxdWVzdBI2CgVsZWFzZRgBIAEoCzIfLnNhYXMuam9icy52MS5Kb2JMZWFzZVJlZmVyZW5jZUIGukgDyAEBEjEKB2ZhaWx1cmUYAiABKAsyGC5zYWFzLmpvYnMudjEuSm9iRmFpbHVyZUIGukgDyAEBEhEKCXJldHJ5YWJsZRgDIAEoCBIsCghyZXRyeV9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi9gEKF01vZHVsZU5vdGlmeVVzZXJSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESGQoHdXNlcl9pZBgCIAEoCUIIukgFcgOwAQESGQoFdGl0bGUYAyABKAlCCrpIB3IFEAEY5gcSFgoEYm9keRgEIAEoCUIIukgFcgMYgEASFQoEdHlwZRgFIAEoCUIHukgEcgIYQBIcCgphY3Rpb25fdXJsGAYgASgJQgi6SAVyAxiAEBIbCghjYXRlZ29yeRgHIAEoCUIJukgGcgQQARggEiEKD2lkZW1wb3RlbmN5X2tleRgIIAEoCUIIukgFcgMY/wEiRgoYTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlEhcKD25vdGlmaWNhdGlvbl9pZBgBIAEoCRIRCglkZWxpdmVyZWQYAiABKAgi4AEKHE1vZHVsZU5vdGlmeU9yZ0FkbWluc1JlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIZCgV0aXRsZRgCIAEoCUIKukgHcgUQARjmBxIWCgRib2R5GAMgASgJQgi6SAVyAxiAQBIVCgR0eXBlGAQgASgJQge6SARyAhhAEhwKCmFjdGlvbl91cmwYBSABKAlCCLpIBXIDGIAQEhsKCGNhdGVnb3J5GAYgASgJQgm6SAZyBBABGCASIQoPaWRlbXBvdGVuY3lfa2V5GAcgASgJQgi6SAVyAxj/ASIyCh1Nb2R1bGVOb3RpZnlPcmdBZG1pbnNSZXNwb25zZRIRCglkZWxpdmVyZWQYASABKAgiYwoUTW9kdWxlQXBwcm92YWxQb2xpY3kSFwoGcXVvcnVtGAEgASgNQge6SAQqAhhkEh4KDGFwcHJvdmVyX3NldBgCIAMoCUIIukgFkgECEGQSEgoKYWxsb3dfc2VsZhgDIAEoCCKbAQoPTW9kdWxlUmVzdW1lUmVmEi4KBXF1ZXVlGAEgASgJQh+6SBxyGhABGIABMhNeW2Etel1bYS16MC05Xy4tXSokEi4KBXRvcGljGAIgASgJQh+6SBxyGhABGP8BMhNeW2Etel1bYS16MC05Xy4tXSokEigKB3BheWxvYWQYAyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0IpYDChxNb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHAoIcmVzb3VyY2UYAiABKAlCCrpIB3IFEAEYgAESGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIABEigKB3N1YmplY3QYBCABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiAKDHJlcXVlc3RlZF9ieRgFIAEoCUIKukgHcgUQARj/ARI2CgZwb2xpY3kYBiABKAsyJi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsUG9saWN5Ej0KCnJlc3VtZV9yZWYYByABKAsyIS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJlc3VtZVJlZkIGukgDyAEBEi4KCmV4cGlyZXNfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCI+Ch1Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZRIdCgthcHByb3ZhbF9pZBgBIAEoCUIIukgFcgOwAQEiUwoYTW9kdWxlR2V0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHQoLYXBwcm92YWxfaWQYAiABKAlCCLpIBXIDsAEBIvUCCg5Nb2R1bGVBcHByb3ZhbBIKCgJpZBgBIAEoCRIOCgZ0ZW5hbnQYAiABKAkSEAoIcmVzb3VyY2UYAyABKAkSDgoGYWN0aW9uGAQgASgJEigKB3N1YmplY3QYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHJlcXVlc3RlZF9ieRgGIAEoCRIOCgZxdW9ydW0YByABKA0SDQoFc3RhdGUYCCABKAkSNQoKcmVzdW1lX3JlZhgJIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlUmVzdW1lUmVmEi4KCmV4cGlyZXNfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAwgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJwChtNb2R1bGVDYW5jZWxBcHByb3ZhbFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIdCgthcHByb3ZhbF9pZBgCIAEoCUIIukgFcgOwAQESGAoGcmVhc29uGAMgASgJQgi6SAVyAxiACCLuAQobTW9kdWxlRW1pdEF1ZGl0RXZlbnRSZXF1ZXN0Eg4KBnRlbmFudBgBIAEoCRIeCgpldmVudF90eXBlGAIgASgJQgq6SAdyBRABGIABEhkKBWFjdG9yGAMgASgJQgq6SAdyBRABGP8BEhwKCHNvbHV0aW9uGAQgASgJQgq6SAdyBRABGIABEhoKCGVudHJ5X2lkGAUgASgJQgi6SAVyAxj/ARInCgZmaWVsZHMYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiEKD2lkZW1wb3RlbmN5X2tleRgHIAEoCUIIukgFcgMY/wEinwEKG01vZHVsZUF1ZGl0RmllbGREZWNsYXJhdGlvbhIXCgRuYW1lGAEgASgJQgm6SAZyBBABGEASQAoEa2luZBgCIAEoDjImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlQXVkaXRGaWVsZEtpbmRCCrpIB4IBBBABIAASGAoGdmFsdWVzGAMgAygJQgi6SAWSAQIQQBILCgNwaWkYBCABKAgi7wEKH01vZHVsZUF1ZGl0RXZlbnRUeXBlRGVjbGFyYXRpb24SGAoEdHlwZRgBIAEoCUIKukgHcgUQARiAARIdCgtkZXNjcmlwdGlvbhgCIAEoCUIIukgFcgMYgAgSRwoGZmllbGRzGAMgAygLMi0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEZpZWxkRGVjbGFyYXRpb25CCLpIBZIBAhAgEkoKCnZpc2liaWxpdHkYBCABKA4yLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUF1ZGl0RXZlbnRWaXNpYmlsaXR5Qgi6SAWCAQIQASKzAQojTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QSPgoGcHJlZml4GAEgASgJQi66SCtyKRABGIABMiJeW2EtejAtOV0oPzpbYS16MC05Xy1dKlthLXowLTldKT8kEkwKBXR5cGVzGAIgAygLMjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEV2ZW50VHlwZURlY2xhcmF0aW9uQgq6SAeSAQQIARBAIl0KJE1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZRIWCg5hZG1pdHRlZF90eXBlcxgBIAMoCRIdChVuYW1lc3BhY2VzX3Rha2VuX292ZXIYAiADKAkibQoWU3ViamVjdFZpc2liaWxpdHlHcmFudBIjChJ2aXNpYmxlX3N1YmplY3RfaWQYASABKAlCB7pIBHICEAESLgoKZXhwaXJlc19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiYwoiTW9kdWxlTGlzdFN1YmplY3RWaXNpYmlsaXR5UmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEiMKEXZpZXdlcl9zdWJqZWN0X2lkGAIgASgJQgi6SAVyA7ABASJfCiNNb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZRI4CgZncmFudHMYASADKAsyKC5zYWFzLmFjY291bnRzLnYxLlN1YmplY3RWaXNpYmlsaXR5R3JhbnQiVwoaRmV0Y2hEYXRhc291cmNlQmxvYlJlcXVlc3QSGwoJc291cmNlX2lkGAEgASgJQgi6SAVyA7ABARIcCghibG9iX3NoYRgCIAEoCUIKukgHcgUQARj/ASJSChhGZXRjaERhdGFzb3VyY2VCbG9iQ2h1bmsSDAoEZGF0YRgBIAEoDBISCgp0b3RhbF9zaXplGAIgASgDEhQKDGNvbnRlbnRfdHlwZRgDIAEoCSJSChFEYXRhc291cmNlRmlsZVJlZhIbCgdpdGVtX2lkGAEgASgJQgq6SAdyBRABGIAgEiAKDGl0ZW1fdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ASKYAQobRmV0Y2hEYXRhc291cmNlRmlsZXNSZXF1ZXN0EhsKCXNvdXJjZV9pZBgBIAEoCUIIukgFcgOwAQESGwoHdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ARI/CgVmaWxlcxgDIAMoCzIjLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUZpbGVSZWZCC7pICJIBBQgBEOgHIosBChREYXRhc291cmNlUHJvdmVuYW5jZRIRCglzb3VyY2VfaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEhgKEGJvdW5kYXJ5X25vZGVfaWQYAyABKAkSDwoHdmVyc2lvbhgEIAEoCRIPCgdpdGVtX2lkGAUgASgJEhQKDGl0ZW1fdmVyc2lvbhgGIAEoCSLAAQoVRGF0YXNvdXJjZUl0ZW1SZWFkZXJzEjsKBWJhc2lzGAEgASgOMiwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnNCYXNpcxIQCgh1c2VyX2lkcxgCIAMoCRIRCglncm91cF9pZHMYAyADKAkSGAoQYm91bmRhcnlfcmVhZGVycxgEIAEoCBIWCg51bm1hcHBlZF9jb3VudBgFIAEoBRITCgthY2xfdmVyc2lvbhgGIAEoCSK+AQoURGF0YXNvdXJjZUZpbGVIZWFkZXISOgoKcHJvdmVuYW5jZRgBIAEoCzImLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZlbmFuY2USDAoEcGF0aBgCIAEoCRIUCgxjb250ZW50X3R5cGUYAyABKAkSDAoEc2l6ZRgEIAEoAxI4CgdyZWFkZXJzGAUgASgLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnMibgoZRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZRI4CgZoZWFkZXIYASABKAsyJi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VGaWxlSGVhZGVySAASDgoEZGF0YRgCIAEoDEgAQgcKBWZyYW1lIm4KGU1vZHVsZVB1Ymxpc2hFdmVudFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARI3CghlbnZlbG9wZRgCIAEoCzIdLnNhYXMuZXZlbnRzLnYxLkV2ZW50RW52ZWxvcGVCBrpIA8gBASI4ChpNb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZRIaCghldmVudF9pZBgBIAEoCUIIukgFcgOwAQEiyAEKFk1vZHVsZVN1YnNjcmliZVJlcXVlc3QSSwoMdHlwZV9wYXR0ZXJuGAEgASgJQjW6SDJyMBABGIABMileW2Etel1bYS16MC05XSooPzpcLlthLXowLTldKykqKD86XC5cKik/JBIuCgVxdWV1ZRgCIAEoCUIfukgcchoQARiAATITXlthLXpdW2EtejAtOV8uLV0qJBIxCghkZWxpdmVyeRgDIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeSLJAQoSTW9kdWxlU3Vic2NyaXB0aW9uEgoKAmlkGAEgASgJEh8KF3N1YnNjcmliZXJfcHJpbmNpcGFsX2lkGAIgASgJEhQKDHR5cGVfcGF0dGVybhgDIAEoCRINCgVxdWV1ZRgEIAEoCRIxCghkZWxpdmVyeRgFIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJVChdNb2R1bGVTdWJzY3JpYmVSZXNwb25zZRI6CgxzdWJzY3JpcHRpb24YASABKAsyJC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVN1YnNjcmlwdGlvbiI9ChhNb2R1bGVVbnN1YnNjcmliZVJlcXVlc3QSIQoPc3Vic2NyaXB0aW9uX2lkGAEgASgJQgi6SAVyA7ABASIgCh5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QiXgofTW9kdWxlTGlzdFN1YnNjcmlwdGlvbnNSZXNwb25zZRI7Cg1zdWJzY3JpcHRpb25zGAEgAygLMiQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVTdWJzY3JpcHRpb24iegoZTW9kdWxlUmVwbGF5RXZlbnRzUmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEhgKBHR5cGUYAiABKAlCCrpIB3IFEAEYgAESKQoFc2luY2UYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIjEKGk1vZHVsZVJlcGxheUV2ZW50c1Jlc3BvbnNlEhMKC3JlZGVsaXZlcmVkGAEgASgFItMCChhSZWFkYWJsZVNvdXJjZUNvbGxlY3Rpb24SEQoJc291cmNlX2lkGAEgASgJEhMKC2JvdW5kYXJ5X2lkGAIgASgJEg4KBm9yaWdpbhgDIAEoCRIRCgljb250YWluZXIYBCABKAkSCwoDcmVmGAUgASgJEg0KBXBhdGhzGAYgAygJEhYKDmJvdW5kYXJ5X2xhYmVsGAcgASgJEj4KEGdyYW50X2Rpc2Nsb3N1cmUYCCABKA4yJC5zYWFzLmFjY291bnRzLnYxLk1ldGFkYXRhRGlzY2xvc3VyZRI+CgtyZWFkX2dyYW50cxgJIAMoCzIpLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVDb2xsZWN0aW9uR3JhbnQSOAoEc3luYxgKIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuQ29sbGVjdGlvblN5bmNQcm92ZW5hbmNlIrABChdSZWFkYWJsZUNvbGxlY3Rpb25HcmFudBIVCg1zdWJqZWN0X2xhYmVsGAEgASgJEhQKDHN1YmplY3Rfa2luZBgCIAEoCRIRCglyb2xlX25hbWUYAyABKAkSEgoKc2NvcGVfcGF0aBgEIAEoCRIRCglpbmhlcml0ZWQYBSABKAgSLgoKZXhwaXJlc19hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiqQIKGENvbGxlY3Rpb25TeW5jUHJvdmVuYW5jZRIwCgVzdGFnZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1N0YWdlEiYKAmF0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIQCghyZXZpc2lvbhgDIAEoCRIPCgd0cmlnZ2VyGAQgASgJEkIKFHJlcXVlc3Rlcl9kaXNjbG9zdXJlGAUgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5NZXRhZGF0YURpc2Nsb3N1cmUSMAoMcmVxdWVzdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIaChJyZXF1ZXN0ZWRfYnlfbGFiZWwYByABKAkiYwokTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Eh0KCXBhZ2Vfc2l6ZRgBIAEoBUIKukgHGgUY6AcoABIcCgpwYWdlX3Rva2VuGAIgASgJQgi6SAVyAxiAECKBAQolTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXNwb25zZRI/Cgtjb2xsZWN0aW9ucxgBIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSLpAQoYTW9kdWxlUGxhY2VSZWNvcmRSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESPQoKc2NvcGVfcGF0aBgCIAEoCUIpukgmciQQARiABDIdXlthLXowLTlfXSsoPzpcLlthLXowLTlfXSspKiQSFwoEa2luZBgDIAEoCUIJukgGcgQQARhAEhcKBWxhYmVsGAQgASgJQgi6SAVyAxj/ARIhCg1yZXNvdXJjZV90eXBlGAUgASgJQgq6SAdyBRABGIABEh8KC3Jlc291cmNlX2lkGAYgASgJQgq6SAdyBRABGIAEIjYKGU1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2USGQoHbm9kZV9pZBgBIAEoCUIIukgFcgOwAQEihQEKI0NoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXF1ZXN0EiEKDXJlc291cmNlX3R5cGUYASABKAlCCrpIB3IFEAEYgAISHwoLcmVzb3VyY2VfaWQYAiABKAlCCrpIB3IFEAEYgAQSGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIACIk4KJENoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZRIPCgdhbGxvd2VkGAEgASgIEhUKDXNjb3BlX25vZGVfaWQYAiABKAkifAoqTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRSZWFkQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAIigAMKL01vZHVsZUV4Y2hhbmdlRGVsZWdhdGVkT3BlcmF0aW9uQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLAoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUIJukgGcgQYgIACEg4KBmxvb2t1cBgDIAEoCBIiCg1kZWxlZ2F0aW9uX2lkGAQgASgJQgu6SAjYAQFyA7ABATrKAbpIxgEawwEKM2V4Y2hhbmdlX2RlbGVnYXRlZF9vcGVyYXRpb25fYXVkaWVuY2Uub25lX2F1dGhvcml0eRJGZXhhY3RseSBvbmUgb2YgcGFyZW50X3dvcmtfY29udGV4dF90b2tlbiBhbmQgZGVsZWdhdGlvbl9pZCBpcyByZXF1aXJlZBpEKHRoaXMucGFyZW50X3dvcmtfY29udGV4dF90b2tlbiAhPSAnJykgIT0gKHRoaXMuZGVsZWdhdGlvbl9pZCAhPSAnJykifQoaRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3QSFwoEa2luZBgBIAEoCUIJukgGcgQQARhAEhgKBG5hbWUYAiABKAlCCrpIB3IFEAEYgAQSLAoGZGlnZXN0GAMgASgJQhy6SBlyFzIVXnNoYTI1NjpbMC05YS1mXXs2NH0kIuIBChpFeGVjdXRhYmxlQXJ0aWZhY3RJZGVudGl0eRIaCgZzY2hlbWEYASABKAlCCrpIB3IFEAEYgAESGgoGc291cmNlGAIgASgJQgq6SAdyBRABGIAEEhwKB3N1YmplY3QYAyABKAxCC7pICHoGEAEYgIAEEkoKCWNvbnRyYWN0cxgEIAMoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3RCCbpIBpIBAxCAAhIiChFleHBlY3RlZF9yZXZpc2lvbhgFIAEoA0IHukgEIgIoACLbAQofTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KCXBvbGljeV9pZBgDIAEoCUIKukgHcgUQARiAARJGCghpZGVudGl0eRgEIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0SWRlbnRpdHlCBrpIA8gBASKZAQolTW9kdWxlUmV2b2tlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KC2FwcHJvdmFsX2lkGAMgASgJQgi6SAVyA7ABASJ9CiBNb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZRIWCg5zY2hlbWFfdmVyc2lvbhgBIAEoCRIWCg5xdWFsaWZpZWRfbmFtZRgCIAEoCRIOCgZkaWdlc3QYAyABKAkSGQoRZXhwZWN0ZWRfcmV2aXNpb24YBCABKAMidQogTW9kdWxlQ3VycmVudEluc3RhbGxhdGlvblJlcXVlc3QSLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgBIAEoCUILukgIcgYQARiAgAISIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASJsCiFNb2R1bGVDdXJyZW50SW5zdGFsbGF0aW9uUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEhEKCXRlbmFudF9pZBgCIAEoCRIbChNzb2x1dGlvbl9pZGVudGlmaWVyGAMgASgJKrgCChRNb2R1bGVBdWRpdEZpZWxkS2luZBInCiNNT0RVTEVfQVVESVRfRklFTERfS0lORF9VTlNQRUNJRklFRBAAEiIKHk1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklORxABEiAKHE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1VVSUQQAhIfChtNT0RVTEVfQVVESVRfRklFTERfS0lORF9JTlQQAxIiCh5NT0RVTEVfQVVESVRfRklFTERfS0lORF9OVU1CRVIQBBIgChxNT0RVTEVfQVVESVRfRklFTERfS0lORF9CT09MEAUSIAocTU9EVUxFX0FVRElUX0ZJRUxEX0tJTkRfRU5VTRAGEigKJE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklOR19BUlJBWRAHKqEBChpNb2R1bGVBdWRpdEV2ZW50VmlzaWJpbGl0eRItCilNT0RVTEVfQVVESVRfRVZFTlRfVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEigKJE1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX1RFTkFOVBABEioKJk1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX0VYVEVSTkFMEAIq3AEKGkRhdGFzb3VyY2VJdGVtUmVhZGVyc0Jhc2lzEi0KKURBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1VOU1BFQ0lGSUVEEAASLAooREFUQVNPVVJDRV9JVEVNX1JFQURFUlNfQkFTSVNfVFJBTlNMQVRFRBABEi8KK0RBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1NPVVJDRV9TQ09QRUQQAhIwCixEQVRBU09VUkNFX0lURU1fUkVBREVSU19CQVNJU19VTlRSQU5TTEFUQUJMRRADKmkKDUV2ZW50RGVsaXZlcnkSHgoaRVZFTlRfREVMSVZFUllfVU5TUEVDSUZJRUQQABIcChhFVkVOVF9ERUxJVkVSWV9VTk9SREVSRUQQARIaChZFVkVOVF9ERUxJVkVSWV9PUkRFUkVEEAIqfgoSTWV0YWRhdGFEaXNjbG9zdXJlEiMKH01FVEFEQVRBX0RJU0NMT1NVUkVfVU5TUEVDSUZJRUQQABIhCh1NRVRBREFUQV9ESVNDTE9TVVJFX0RJU0NMT1NFRBABEiAKHE1FVEFEQVRBX0RJU0NMT1NVUkVfV0lUSEhFTEQQAipcCg9Tb3VyY2VTeW5jU3RhZ2USIQodU09VUkNFX1NZTkNfU1RBR0VfVU5TUEVDSUZJRUQQABImCiJTT1VSQ0VfU1lOQ19TVEFHRV9DSEFOR0VTX0VOUVVFVUVEEAEypykKGU1vZHVsZUNhcGFiaWxpdGllc1NlcnZpY2USmwEKFkdldEN1cnJlbnRJbnN0YWxsYXRpb24SMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDdXJyZW50SW5zdGFsbGF0aW9uUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1AEWANgARLGAQoZQXBwcm92ZUV4ZWN1dGFibGVBcnRpZmFjdBIxLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBoyLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVzcG9uc2UiQsLzGD4IAxABMAE6LAooc2Fhcy5tb2R1bGUuZXhlY3V0YWJsZV9hcnRpZmFjdF9kZWNpc2lvbhACQAFIB1AEWAJgARKeAQobQXV0aG9yaXplRXhlY3V0YWJsZUFydGlmYWN0EjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUARYAmABEssBChhSZXZva2VFeGVjdXRhYmxlQXJ0aWZhY3QSNy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJldm9rZUV4ZWN1dGFibGVBcnRpZmFjdFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUV4ZWN1dGFibGVBcnRpZmFjdFJlc3BvbnNlIkLC8xg+CAMQATABOiwKKHNhYXMubW9kdWxlLmV4ZWN1dGFibGVfYXJ0aWZhY3RfZGVjaXNpb24QAkABSAdQBFgCYAESxQEKHUV4Y2hhbmdlRGVsZWdhdGVkUmVhZEF1ZGllbmNlEjwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGNoYW5nZURlbGVnYXRlZFJlYWRBdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARLPAQoiRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZRJBLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARKnAQocQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2VzcxI1LnNhYXMuYWNjb3VudHMudjEuQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2Vzc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkNoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEqoBCh1MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9ucxI2LnNhYXMuYWNjb3VudHMudjEuTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Gjcuc2Fhcy5hY2NvdW50cy52MS5MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESgAEKC1BsYWNlUmVjb3JkEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQbGFjZVJlY29yZFJlcXVlc3QaKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpFbnF1ZXVlSm9iEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbnF1ZXVlSm9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESegoJQ2xhaW1Kb2JzEiguc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXF1ZXN0Gikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEoMBCgxIZWFydGJlYXRKb2ISKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYQoGQWNrSm9iEiUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBY2tKb2JSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYwoHTmFja0pvYhImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTmFja0pvYlJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpOb3RpZnlVc2VyEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVOb3RpZnlVc2VyUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESrQEKD05vdGlmeU9yZ0FkbWlucxIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUub3JnX2FkbWluc19ub3RpZmllZBACQAFIB1ADWANgARKMAQoPUmVxdWVzdEFwcHJvdmFsEi4uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnUKC0dldEFwcHJvdmFsEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVHZXRBcHByb3ZhbFJlcXVlc3QaIC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAEScQoOQ2FuY2VsQXBwcm92YWwSLS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUNhbmNlbEFwcHJvdmFsUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnEKDkVtaXRBdWRpdEV2ZW50Ei0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbWl0QXVkaXRFdmVudFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLDAQoWRGVjbGFyZUF1ZGl0RXZlbnRUeXBlcxI1LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZSI6wvMYNggDEAEwATokCiBzYWFzLm1vZHVsZS5hdWRpdF90eXBlc19kZWNsYXJlZBACQAFIB1ADWANgARKeAQoVTGlzdFN1YmplY3RWaXNpYmlsaXR5EjQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEo4BChNGZXRjaERhdGFzb3VyY2VCbG9iEiwuc2Fhcy5hY2NvdW50cy52MS5GZXRjaERhdGFzb3VyY2VCbG9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlQmxvYkNodW5rIhuIAgHC8xgUCAMQATABOgIQAUABSAdQA1gDYAEwARKOAQoURmV0Y2hEYXRhc291cmNlRmlsZXMSLS5zYWFzLmFjY291bnRzLnYxLkZldGNoRGF0YXNvdXJjZUZpbGVzUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABMAEStgEKFk1pbnRNb2R1bGVSZWdpc3RyYXRpb24SLy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50UmVnaXN0cmF0aW9uUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUucmVnaXN0cmF0aW9uX21pbnRlZBACQAFIB1AEWARgARK+AQoYTWludFNvbHV0aW9uUmVnaXN0cmF0aW9uEjEuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXNwb25zZSI7wvMYNwgDEAEwATolCiFzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl9taW50ZWQQAkABSAdQBFgEYAESswEKFU1pbnRNb2R1bGVXb3JrQ29udGV4dBIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUud29ya19jb250ZXh0X21pbnRlZBACQAFIB1AEWARgARLHAQoaTWludE1vZHVsZU9wZXJhdGlvbkNvbnRleHQSMy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRPcGVyYXRpb25Db250ZXh0UmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludE9wZXJhdGlvbkNvbnRleHRSZXNwb25zZSI+wvMYOggDEAEwATooCiRzYWFzLm1vZHVsZS5vcGVyYXRpb25fY29udGV4dF9taW50ZWQQAkABSAdQBFgEYAESzgEKGk1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0Ejkuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50U291cmNlT3BlcmF0aW9uQ29udGV4dFJlcXVlc3QaOi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24udXNlZBACQAFIB1AEWARgARKDAQoMUHVibGlzaEV2ZW50Eisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKCVN1YnNjcmliZRIoLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJrCgtVbnN1YnNjcmliZRIqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlVW5zdWJzY3JpYmVSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESkgEKEUxpc3RTdWJzY3JpcHRpb25zEjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QaMS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUxpc3RTdWJzY3JpcHRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARKDAQoMUmVwbGF5RXZlbnRzEisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABYgZwcm90bzM", [file_saas_accounts_v1_work_contexts, file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_accounts_v1_module_registration, file_saas_events_v1_events, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); + fileDesc("CipzYWFzL2FjY291bnRzL3YxL21vZHVsZV9jYXBhYmlsaXRpZXMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoXTW9kdWxlRW5xdWV1ZUpvYlJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIpCgNqb2IYAiABKAsyFC5zYWFzLmpvYnMudjEuTmV3Sm9iQga6SAPIAQEidgoYTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlEhgKBmpvYl9pZBgBIAEoCUIIukgFcgOwAQESQAoLZGlzcG9zaXRpb24YAiABKA4yIy5zYWFzLmpvYnMudjEuSm9iRW5xdWV1ZURpc3Bvc2l0aW9uQga6SAPIAQEiygEKFk1vZHVsZUNsYWltSm9ic1JlcXVlc3QSLgoFcXVldWUYASABKAlCH7pIHHIaEAEYgAEyE15bYS16XVthLXowLTlfLi1dKiQSHQoJd29ya2VyX2lkGAIgASgJQgq6SAdyBRABGP8BEhgKBWxpbWl0GAMgASgNQgm6SAYqBBhkKAESRwoObGVhc2VfZHVyYXRpb24YBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IkIKF01vZHVsZUNsYWltSm9ic1Jlc3BvbnNlEicKBGpvYnMYASADKAsyGS5zYWFzLmpvYnMudjEuSm9iRW52ZWxvcGUilwEKGU1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARJCCglleHRlbnNpb24YAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IksKGk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlEi0KBWxlYXNlGAEgASgLMhYuc2Fhcy5qb2JzLnYxLkpvYkxlYXNlQga6SAPIAQEipgEKE01vZHVsZUFja0pvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARI3Cg5leGVjdXRpb25fa2luZBgCIAEoCUIfukgcchoYQDIWXiR8XlthLXpdW2EtejAtOV8uLV0qJBIeCgxleGVjdXRpb25faWQYAyABKAlCCLpIBXIDGP8BIsIBChRNb2R1bGVOYWNrSm9iUmVxdWVzdBI2CgVsZWFzZRgBIAEoCzIfLnNhYXMuam9icy52MS5Kb2JMZWFzZVJlZmVyZW5jZUIGukgDyAEBEjEKB2ZhaWx1cmUYAiABKAsyGC5zYWFzLmpvYnMudjEuSm9iRmFpbHVyZUIGukgDyAEBEhEKCXJldHJ5YWJsZRgDIAEoCBIsCghyZXRyeV9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi9gEKF01vZHVsZU5vdGlmeVVzZXJSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESGQoHdXNlcl9pZBgCIAEoCUIIukgFcgOwAQESGQoFdGl0bGUYAyABKAlCCrpIB3IFEAEY5gcSFgoEYm9keRgEIAEoCUIIukgFcgMYgEASFQoEdHlwZRgFIAEoCUIHukgEcgIYQBIcCgphY3Rpb25fdXJsGAYgASgJQgi6SAVyAxiAEBIbCghjYXRlZ29yeRgHIAEoCUIJukgGcgQQARggEiEKD2lkZW1wb3RlbmN5X2tleRgIIAEoCUIIukgFcgMY/wEiRgoYTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlEhcKD25vdGlmaWNhdGlvbl9pZBgBIAEoCRIRCglkZWxpdmVyZWQYAiABKAgi4AEKHE1vZHVsZU5vdGlmeU9yZ0FkbWluc1JlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIZCgV0aXRsZRgCIAEoCUIKukgHcgUQARjmBxIWCgRib2R5GAMgASgJQgi6SAVyAxiAQBIVCgR0eXBlGAQgASgJQge6SARyAhhAEhwKCmFjdGlvbl91cmwYBSABKAlCCLpIBXIDGIAQEhsKCGNhdGVnb3J5GAYgASgJQgm6SAZyBBABGCASIQoPaWRlbXBvdGVuY3lfa2V5GAcgASgJQgi6SAVyAxj/ASIyCh1Nb2R1bGVOb3RpZnlPcmdBZG1pbnNSZXNwb25zZRIRCglkZWxpdmVyZWQYASABKAgiYwoUTW9kdWxlQXBwcm92YWxQb2xpY3kSFwoGcXVvcnVtGAEgASgNQge6SAQqAhhkEh4KDGFwcHJvdmVyX3NldBgCIAMoCUIIukgFkgECEGQSEgoKYWxsb3dfc2VsZhgDIAEoCCKbAQoPTW9kdWxlUmVzdW1lUmVmEi4KBXF1ZXVlGAEgASgJQh+6SBxyGhABGIABMhNeW2Etel1bYS16MC05Xy4tXSokEi4KBXRvcGljGAIgASgJQh+6SBxyGhABGP8BMhNeW2Etel1bYS16MC05Xy4tXSokEigKB3BheWxvYWQYAyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0IpYDChxNb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHAoIcmVzb3VyY2UYAiABKAlCCrpIB3IFEAEYgAESGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIABEigKB3N1YmplY3QYBCABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiAKDHJlcXVlc3RlZF9ieRgFIAEoCUIKukgHcgUQARj/ARI2CgZwb2xpY3kYBiABKAsyJi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsUG9saWN5Ej0KCnJlc3VtZV9yZWYYByABKAsyIS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJlc3VtZVJlZkIGukgDyAEBEi4KCmV4cGlyZXNfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCI+Ch1Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZRIdCgthcHByb3ZhbF9pZBgBIAEoCUIIukgFcgOwAQEiUwoYTW9kdWxlR2V0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHQoLYXBwcm92YWxfaWQYAiABKAlCCLpIBXIDsAEBIvUCCg5Nb2R1bGVBcHByb3ZhbBIKCgJpZBgBIAEoCRIOCgZ0ZW5hbnQYAiABKAkSEAoIcmVzb3VyY2UYAyABKAkSDgoGYWN0aW9uGAQgASgJEigKB3N1YmplY3QYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHJlcXVlc3RlZF9ieRgGIAEoCRIOCgZxdW9ydW0YByABKA0SDQoFc3RhdGUYCCABKAkSNQoKcmVzdW1lX3JlZhgJIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlUmVzdW1lUmVmEi4KCmV4cGlyZXNfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAwgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJwChtNb2R1bGVDYW5jZWxBcHByb3ZhbFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIdCgthcHByb3ZhbF9pZBgCIAEoCUIIukgFcgOwAQESGAoGcmVhc29uGAMgASgJQgi6SAVyAxiACCLuAQobTW9kdWxlRW1pdEF1ZGl0RXZlbnRSZXF1ZXN0Eg4KBnRlbmFudBgBIAEoCRIeCgpldmVudF90eXBlGAIgASgJQgq6SAdyBRABGIABEhkKBWFjdG9yGAMgASgJQgq6SAdyBRABGP8BEhwKCHNvbHV0aW9uGAQgASgJQgq6SAdyBRABGIABEhoKCGVudHJ5X2lkGAUgASgJQgi6SAVyAxj/ARInCgZmaWVsZHMYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiEKD2lkZW1wb3RlbmN5X2tleRgHIAEoCUIIukgFcgMY/wEinwEKG01vZHVsZUF1ZGl0RmllbGREZWNsYXJhdGlvbhIXCgRuYW1lGAEgASgJQgm6SAZyBBABGEASQAoEa2luZBgCIAEoDjImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlQXVkaXRGaWVsZEtpbmRCCrpIB4IBBBABIAASGAoGdmFsdWVzGAMgAygJQgi6SAWSAQIQQBILCgNwaWkYBCABKAgi7wEKH01vZHVsZUF1ZGl0RXZlbnRUeXBlRGVjbGFyYXRpb24SGAoEdHlwZRgBIAEoCUIKukgHcgUQARiAARIdCgtkZXNjcmlwdGlvbhgCIAEoCUIIukgFcgMYgAgSRwoGZmllbGRzGAMgAygLMi0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEZpZWxkRGVjbGFyYXRpb25CCLpIBZIBAhAgEkoKCnZpc2liaWxpdHkYBCABKA4yLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUF1ZGl0RXZlbnRWaXNpYmlsaXR5Qgi6SAWCAQIQASKzAQojTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QSPgoGcHJlZml4GAEgASgJQi66SCtyKRABGIABMiJeW2EtejAtOV0oPzpbYS16MC05Xy1dKlthLXowLTldKT8kEkwKBXR5cGVzGAIgAygLMjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEV2ZW50VHlwZURlY2xhcmF0aW9uQgq6SAeSAQQIARBAIl0KJE1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZRIWCg5hZG1pdHRlZF90eXBlcxgBIAMoCRIdChVuYW1lc3BhY2VzX3Rha2VuX292ZXIYAiADKAkibQoWU3ViamVjdFZpc2liaWxpdHlHcmFudBIjChJ2aXNpYmxlX3N1YmplY3RfaWQYASABKAlCB7pIBHICEAESLgoKZXhwaXJlc19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiYwoiTW9kdWxlTGlzdFN1YmplY3RWaXNpYmlsaXR5UmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEiMKEXZpZXdlcl9zdWJqZWN0X2lkGAIgASgJQgi6SAVyA7ABASJfCiNNb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZRI4CgZncmFudHMYASADKAsyKC5zYWFzLmFjY291bnRzLnYxLlN1YmplY3RWaXNpYmlsaXR5R3JhbnQiVwoaRmV0Y2hEYXRhc291cmNlQmxvYlJlcXVlc3QSGwoJc291cmNlX2lkGAEgASgJQgi6SAVyA7ABARIcCghibG9iX3NoYRgCIAEoCUIKukgHcgUQARj/ASJSChhGZXRjaERhdGFzb3VyY2VCbG9iQ2h1bmsSDAoEZGF0YRgBIAEoDBISCgp0b3RhbF9zaXplGAIgASgDEhQKDGNvbnRlbnRfdHlwZRgDIAEoCSJSChFEYXRhc291cmNlRmlsZVJlZhIbCgdpdGVtX2lkGAEgASgJQgq6SAdyBRABGIAgEiAKDGl0ZW1fdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ASKYAQobRmV0Y2hEYXRhc291cmNlRmlsZXNSZXF1ZXN0EhsKCXNvdXJjZV9pZBgBIAEoCUIIukgFcgOwAQESGwoHdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ARI/CgVmaWxlcxgDIAMoCzIjLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUZpbGVSZWZCC7pICJIBBQgBEOgHIosBChREYXRhc291cmNlUHJvdmVuYW5jZRIRCglzb3VyY2VfaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEhgKEGJvdW5kYXJ5X25vZGVfaWQYAyABKAkSDwoHdmVyc2lvbhgEIAEoCRIPCgdpdGVtX2lkGAUgASgJEhQKDGl0ZW1fdmVyc2lvbhgGIAEoCSLAAQoVRGF0YXNvdXJjZUl0ZW1SZWFkZXJzEjsKBWJhc2lzGAEgASgOMiwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnNCYXNpcxIQCgh1c2VyX2lkcxgCIAMoCRIRCglncm91cF9pZHMYAyADKAkSGAoQYm91bmRhcnlfcmVhZGVycxgEIAEoCBIWCg51bm1hcHBlZF9jb3VudBgFIAEoBRITCgthY2xfdmVyc2lvbhgGIAEoCSK+AQoURGF0YXNvdXJjZUZpbGVIZWFkZXISOgoKcHJvdmVuYW5jZRgBIAEoCzImLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZlbmFuY2USDAoEcGF0aBgCIAEoCRIUCgxjb250ZW50X3R5cGUYAyABKAkSDAoEc2l6ZRgEIAEoAxI4CgdyZWFkZXJzGAUgASgLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnMibgoZRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZRI4CgZoZWFkZXIYASABKAsyJi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VGaWxlSGVhZGVySAASDgoEZGF0YRgCIAEoDEgAQgcKBWZyYW1lIm4KGU1vZHVsZVB1Ymxpc2hFdmVudFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARI3CghlbnZlbG9wZRgCIAEoCzIdLnNhYXMuZXZlbnRzLnYxLkV2ZW50RW52ZWxvcGVCBrpIA8gBASI4ChpNb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZRIaCghldmVudF9pZBgBIAEoCUIIukgFcgOwAQEiyAEKFk1vZHVsZVN1YnNjcmliZVJlcXVlc3QSSwoMdHlwZV9wYXR0ZXJuGAEgASgJQjW6SDJyMBABGIABMileW2Etel1bYS16MC05XSooPzpcLlthLXowLTldKykqKD86XC5cKik/JBIuCgVxdWV1ZRgCIAEoCUIfukgcchoQARiAATITXlthLXpdW2EtejAtOV8uLV0qJBIxCghkZWxpdmVyeRgDIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeSLJAQoSTW9kdWxlU3Vic2NyaXB0aW9uEgoKAmlkGAEgASgJEh8KF3N1YnNjcmliZXJfcHJpbmNpcGFsX2lkGAIgASgJEhQKDHR5cGVfcGF0dGVybhgDIAEoCRINCgVxdWV1ZRgEIAEoCRIxCghkZWxpdmVyeRgFIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJVChdNb2R1bGVTdWJzY3JpYmVSZXNwb25zZRI6CgxzdWJzY3JpcHRpb24YASABKAsyJC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVN1YnNjcmlwdGlvbiI9ChhNb2R1bGVVbnN1YnNjcmliZVJlcXVlc3QSIQoPc3Vic2NyaXB0aW9uX2lkGAEgASgJQgi6SAVyA7ABASIgCh5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QiXgofTW9kdWxlTGlzdFN1YnNjcmlwdGlvbnNSZXNwb25zZRI7Cg1zdWJzY3JpcHRpb25zGAEgAygLMiQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVTdWJzY3JpcHRpb24iegoZTW9kdWxlUmVwbGF5RXZlbnRzUmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEhgKBHR5cGUYAiABKAlCCrpIB3IFEAEYgAESKQoFc2luY2UYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIjEKGk1vZHVsZVJlcGxheUV2ZW50c1Jlc3BvbnNlEhMKC3JlZGVsaXZlcmVkGAEgASgFItMCChhSZWFkYWJsZVNvdXJjZUNvbGxlY3Rpb24SEQoJc291cmNlX2lkGAEgASgJEhMKC2JvdW5kYXJ5X2lkGAIgASgJEg4KBm9yaWdpbhgDIAEoCRIRCgljb250YWluZXIYBCABKAkSCwoDcmVmGAUgASgJEg0KBXBhdGhzGAYgAygJEhYKDmJvdW5kYXJ5X2xhYmVsGAcgASgJEj4KEGdyYW50X2Rpc2Nsb3N1cmUYCCABKA4yJC5zYWFzLmFjY291bnRzLnYxLk1ldGFkYXRhRGlzY2xvc3VyZRI+CgtyZWFkX2dyYW50cxgJIAMoCzIpLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVDb2xsZWN0aW9uR3JhbnQSOAoEc3luYxgKIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuQ29sbGVjdGlvblN5bmNQcm92ZW5hbmNlIrABChdSZWFkYWJsZUNvbGxlY3Rpb25HcmFudBIVCg1zdWJqZWN0X2xhYmVsGAEgASgJEhQKDHN1YmplY3Rfa2luZBgCIAEoCRIRCglyb2xlX25hbWUYAyABKAkSEgoKc2NvcGVfcGF0aBgEIAEoCRIRCglpbmhlcml0ZWQYBSABKAgSLgoKZXhwaXJlc19hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiqQIKGENvbGxlY3Rpb25TeW5jUHJvdmVuYW5jZRIwCgVzdGFnZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1N0YWdlEiYKAmF0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIQCghyZXZpc2lvbhgDIAEoCRIPCgd0cmlnZ2VyGAQgASgJEkIKFHJlcXVlc3Rlcl9kaXNjbG9zdXJlGAUgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5NZXRhZGF0YURpc2Nsb3N1cmUSMAoMcmVxdWVzdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIaChJyZXF1ZXN0ZWRfYnlfbGFiZWwYByABKAkiYwokTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Eh0KCXBhZ2Vfc2l6ZRgBIAEoBUIKukgHGgUY6AcoABIcCgpwYWdlX3Rva2VuGAIgASgJQgi6SAVyAxiAECKBAQolTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXNwb25zZRI/Cgtjb2xsZWN0aW9ucxgBIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSLpAQoYTW9kdWxlUGxhY2VSZWNvcmRSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESPQoKc2NvcGVfcGF0aBgCIAEoCUIpukgmciQQARiABDIdXlthLXowLTlfXSsoPzpcLlthLXowLTlfXSspKiQSFwoEa2luZBgDIAEoCUIJukgGcgQQARhAEhcKBWxhYmVsGAQgASgJQgi6SAVyAxj/ARIhCg1yZXNvdXJjZV90eXBlGAUgASgJQgq6SAdyBRABGIABEh8KC3Jlc291cmNlX2lkGAYgASgJQgq6SAdyBRABGIAEIjYKGU1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2USGQoHbm9kZV9pZBgBIAEoCUIIukgFcgOwAQEihQEKI0NoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXF1ZXN0EiEKDXJlc291cmNlX3R5cGUYASABKAlCCrpIB3IFEAEYgAISHwoLcmVzb3VyY2VfaWQYAiABKAlCCrpIB3IFEAEYgAQSGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIACIk4KJENoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZRIPCgdhbGxvd2VkGAEgASgIEhUKDXNjb3BlX25vZGVfaWQYAiABKAkifAoqTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRSZWFkQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAIigAMKL01vZHVsZUV4Y2hhbmdlRGVsZWdhdGVkT3BlcmF0aW9uQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLAoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUIJukgGcgQYgIACEg4KBmxvb2t1cBgDIAEoCBIiCg1kZWxlZ2F0aW9uX2lkGAQgASgJQgu6SAjYAQFyA7ABATrKAbpIxgEawwEKM2V4Y2hhbmdlX2RlbGVnYXRlZF9vcGVyYXRpb25fYXVkaWVuY2Uub25lX2F1dGhvcml0eRJGZXhhY3RseSBvbmUgb2YgcGFyZW50X3dvcmtfY29udGV4dF90b2tlbiBhbmQgZGVsZWdhdGlvbl9pZCBpcyByZXF1aXJlZBpEKHRoaXMucGFyZW50X3dvcmtfY29udGV4dF90b2tlbiAhPSAnJykgIT0gKHRoaXMuZGVsZWdhdGlvbl9pZCAhPSAnJykifQoaRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3QSFwoEa2luZBgBIAEoCUIJukgGcgQQARhAEhgKBG5hbWUYAiABKAlCCrpIB3IFEAEYgAQSLAoGZGlnZXN0GAMgASgJQhy6SBlyFzIVXnNoYTI1NjpbMC05YS1mXXs2NH0kIuIBChpFeGVjdXRhYmxlQXJ0aWZhY3RJZGVudGl0eRIaCgZzY2hlbWEYASABKAlCCrpIB3IFEAEYgAESGgoGc291cmNlGAIgASgJQgq6SAdyBRABGIAEEhwKB3N1YmplY3QYAyABKAxCC7pICHoGEAEYgIAEEkoKCWNvbnRyYWN0cxgEIAMoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3RCCbpIBpIBAxCAAhIiChFleHBlY3RlZF9yZXZpc2lvbhgFIAEoA0IHukgEIgIoACLbAQofTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KCXBvbGljeV9pZBgDIAEoCUIKukgHcgUQARiAARJGCghpZGVudGl0eRgEIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0SWRlbnRpdHlCBrpIA8gBASKZAQolTW9kdWxlUmV2b2tlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KC2FwcHJvdmFsX2lkGAMgASgJQgi6SAVyA7ABASJ9CiBNb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZRIWCg5zY2hlbWFfdmVyc2lvbhgBIAEoCRIWCg5xdWFsaWZpZWRfbmFtZRgCIAEoCRIOCgZkaWdlc3QYAyABKAkSGQoRZXhwZWN0ZWRfcmV2aXNpb24YBCABKAMidQogTW9kdWxlQ3VycmVudEluc3RhbGxhdGlvblJlcXVlc3QSLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgBIAEoCUILukgIcgYQARiAgAISIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASKbAQohTW9kdWxlQ3VycmVudEluc3RhbGxhdGlvblJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIRCgl0ZW5hbnRfaWQYAiABKAkSGwoJdGFyZ2V0X2lkGAQgASgJQgi6SAVyA7ABARISCgpiaW5kaW5nX2lkGAUgASgJSgQIAxAEUhNzb2x1dGlvbl9pZGVudGlmaWVyKrgCChRNb2R1bGVBdWRpdEZpZWxkS2luZBInCiNNT0RVTEVfQVVESVRfRklFTERfS0lORF9VTlNQRUNJRklFRBAAEiIKHk1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklORxABEiAKHE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1VVSUQQAhIfChtNT0RVTEVfQVVESVRfRklFTERfS0lORF9JTlQQAxIiCh5NT0RVTEVfQVVESVRfRklFTERfS0lORF9OVU1CRVIQBBIgChxNT0RVTEVfQVVESVRfRklFTERfS0lORF9CT09MEAUSIAocTU9EVUxFX0FVRElUX0ZJRUxEX0tJTkRfRU5VTRAGEigKJE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklOR19BUlJBWRAHKqEBChpNb2R1bGVBdWRpdEV2ZW50VmlzaWJpbGl0eRItCilNT0RVTEVfQVVESVRfRVZFTlRfVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEigKJE1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX1RFTkFOVBABEioKJk1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX0VYVEVSTkFMEAIq3AEKGkRhdGFzb3VyY2VJdGVtUmVhZGVyc0Jhc2lzEi0KKURBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1VOU1BFQ0lGSUVEEAASLAooREFUQVNPVVJDRV9JVEVNX1JFQURFUlNfQkFTSVNfVFJBTlNMQVRFRBABEi8KK0RBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1NPVVJDRV9TQ09QRUQQAhIwCixEQVRBU09VUkNFX0lURU1fUkVBREVSU19CQVNJU19VTlRSQU5TTEFUQUJMRRADKmkKDUV2ZW50RGVsaXZlcnkSHgoaRVZFTlRfREVMSVZFUllfVU5TUEVDSUZJRUQQABIcChhFVkVOVF9ERUxJVkVSWV9VTk9SREVSRUQQARIaChZFVkVOVF9ERUxJVkVSWV9PUkRFUkVEEAIqfgoSTWV0YWRhdGFEaXNjbG9zdXJlEiMKH01FVEFEQVRBX0RJU0NMT1NVUkVfVU5TUEVDSUZJRUQQABIhCh1NRVRBREFUQV9ESVNDTE9TVVJFX0RJU0NMT1NFRBABEiAKHE1FVEFEQVRBX0RJU0NMT1NVUkVfV0lUSEhFTEQQAipcCg9Tb3VyY2VTeW5jU3RhZ2USIQodU09VUkNFX1NZTkNfU1RBR0VfVU5TUEVDSUZJRUQQABImCiJTT1VSQ0VfU1lOQ19TVEFHRV9DSEFOR0VTX0VOUVVFVUVEEAEypykKGU1vZHVsZUNhcGFiaWxpdGllc1NlcnZpY2USmwEKFkdldEN1cnJlbnRJbnN0YWxsYXRpb24SMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDdXJyZW50SW5zdGFsbGF0aW9uUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1AEWANgARLGAQoZQXBwcm92ZUV4ZWN1dGFibGVBcnRpZmFjdBIxLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBoyLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVzcG9uc2UiQsLzGD4IAxABMAE6LAooc2Fhcy5tb2R1bGUuZXhlY3V0YWJsZV9hcnRpZmFjdF9kZWNpc2lvbhACQAFIB1AEWAJgARKeAQobQXV0aG9yaXplRXhlY3V0YWJsZUFydGlmYWN0EjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUARYAmABEssBChhSZXZva2VFeGVjdXRhYmxlQXJ0aWZhY3QSNy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJldm9rZUV4ZWN1dGFibGVBcnRpZmFjdFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUV4ZWN1dGFibGVBcnRpZmFjdFJlc3BvbnNlIkLC8xg+CAMQATABOiwKKHNhYXMubW9kdWxlLmV4ZWN1dGFibGVfYXJ0aWZhY3RfZGVjaXNpb24QAkABSAdQBFgCYAESxQEKHUV4Y2hhbmdlRGVsZWdhdGVkUmVhZEF1ZGllbmNlEjwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGNoYW5nZURlbGVnYXRlZFJlYWRBdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARLPAQoiRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZRJBLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARKnAQocQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2VzcxI1LnNhYXMuYWNjb3VudHMudjEuQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2Vzc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkNoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEqoBCh1MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9ucxI2LnNhYXMuYWNjb3VudHMudjEuTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Gjcuc2Fhcy5hY2NvdW50cy52MS5MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESgAEKC1BsYWNlUmVjb3JkEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQbGFjZVJlY29yZFJlcXVlc3QaKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpFbnF1ZXVlSm9iEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbnF1ZXVlSm9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESegoJQ2xhaW1Kb2JzEiguc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXF1ZXN0Gikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEoMBCgxIZWFydGJlYXRKb2ISKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYQoGQWNrSm9iEiUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBY2tKb2JSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYwoHTmFja0pvYhImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTmFja0pvYlJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpOb3RpZnlVc2VyEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVOb3RpZnlVc2VyUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESrQEKD05vdGlmeU9yZ0FkbWlucxIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUub3JnX2FkbWluc19ub3RpZmllZBACQAFIB1ADWANgARKMAQoPUmVxdWVzdEFwcHJvdmFsEi4uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnUKC0dldEFwcHJvdmFsEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVHZXRBcHByb3ZhbFJlcXVlc3QaIC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAEScQoOQ2FuY2VsQXBwcm92YWwSLS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUNhbmNlbEFwcHJvdmFsUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnEKDkVtaXRBdWRpdEV2ZW50Ei0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbWl0QXVkaXRFdmVudFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLDAQoWRGVjbGFyZUF1ZGl0RXZlbnRUeXBlcxI1LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZSI6wvMYNggDEAEwATokCiBzYWFzLm1vZHVsZS5hdWRpdF90eXBlc19kZWNsYXJlZBACQAFIB1ADWANgARKeAQoVTGlzdFN1YmplY3RWaXNpYmlsaXR5EjQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEo4BChNGZXRjaERhdGFzb3VyY2VCbG9iEiwuc2Fhcy5hY2NvdW50cy52MS5GZXRjaERhdGFzb3VyY2VCbG9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlQmxvYkNodW5rIhuIAgHC8xgUCAMQATABOgIQAUABSAdQA1gDYAEwARKOAQoURmV0Y2hEYXRhc291cmNlRmlsZXMSLS5zYWFzLmFjY291bnRzLnYxLkZldGNoRGF0YXNvdXJjZUZpbGVzUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABMAEStgEKFk1pbnRNb2R1bGVSZWdpc3RyYXRpb24SLy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50UmVnaXN0cmF0aW9uUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUucmVnaXN0cmF0aW9uX21pbnRlZBACQAFIB1AEWARgARK+AQoYTWludFNvbHV0aW9uUmVnaXN0cmF0aW9uEjEuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXNwb25zZSI7wvMYNwgDEAEwATolCiFzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl9taW50ZWQQAkABSAdQBFgEYAESswEKFU1pbnRNb2R1bGVXb3JrQ29udGV4dBIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUud29ya19jb250ZXh0X21pbnRlZBACQAFIB1AEWARgARLHAQoaTWludE1vZHVsZU9wZXJhdGlvbkNvbnRleHQSMy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRPcGVyYXRpb25Db250ZXh0UmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludE9wZXJhdGlvbkNvbnRleHRSZXNwb25zZSI+wvMYOggDEAEwATooCiRzYWFzLm1vZHVsZS5vcGVyYXRpb25fY29udGV4dF9taW50ZWQQAkABSAdQBFgEYAESzgEKGk1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0Ejkuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50U291cmNlT3BlcmF0aW9uQ29udGV4dFJlcXVlc3QaOi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24udXNlZBACQAFIB1AEWARgARKDAQoMUHVibGlzaEV2ZW50Eisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKCVN1YnNjcmliZRIoLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJrCgtVbnN1YnNjcmliZRIqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlVW5zdWJzY3JpYmVSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESkgEKEUxpc3RTdWJzY3JpcHRpb25zEjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QaMS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUxpc3RTdWJzY3JpcHRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARKDAQoMUmVwbGF5RXZlbnRzEisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABYgZwcm90bzM", [file_saas_accounts_v1_work_contexts, file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_accounts_v1_module_registration, file_saas_events_v1_events, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); /** * ModuleEnqueueJobRequest names the tenant the work belongs to and carries the @@ -2170,9 +2170,20 @@ export type ModuleCurrentInstallationResponse = Message<"saas.accounts.v1.Module tenantId: string; /** - * @generated from field: string solution_identifier = 3; + * The immutable target this installation was consented to: one continuous + * period of one binding's presence, never reused. + * + * @generated from field: string target_id = 4; + */ + targetId: string; + + /** + * The delivered presence binding whose period that target records. This is + * what an authority decision keys on; the alias is display only. + * + * @generated from field: string binding_id = 5; */ - solutionIdentifier: string; + bindingId: string; }; /** diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_pb.ts new file mode 100644 index 000000000..f9f1a0b84 --- /dev/null +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_pb.ts @@ -0,0 +1,151 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/accounts/v1/solution_entitlements.proto (package saas.accounts.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file saas/accounts/v1/solution_entitlements.proto. + */ +export const file_saas_accounts_v1_solution_entitlements: GenFile = /*@__PURE__*/ + fileDesc("CixzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX2VudGl0bGVtZW50cy5wcm90bxIQc2Fhcy5hY2NvdW50cy52MSKnAQoTU29sdXRpb25FbnRpdGxlbWVudBIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEiQKEnJvb3Rfc2NvcGVfbm9kZV9pZBgDIAEoCUIIukgFcgOwAQESDwoHaGVhbHRoeRgEIAEoCBIbCgl0YXJnZXRfaWQYBSABKAlCCLpIBXIDsAEBSgQIARACUhNzb2x1dGlvbl9pZGVudGlmaWVyIowBCh9MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoKc3ViamVjdF9pZBgCIAEoCUIIukgFcgOwAQESHQoJcGFnZV9zaXplGAMgASgFQgq6SAcaBRj0AygAEhIKCnBhZ2VfdG9rZW4YBCABKAkieAogTGlzdFNvbHV0aW9uRW50aXRsZW1lbnRzUmVzcG9uc2USOwoMZW50aXRsZW1lbnRzGAEgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkVudGl0bGVtZW50EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCWIGcHJvdG8z", [file_buf_validate_validate]); + +/** + * SolutionEntitlement is one installed solution a subject may use. It is + * deliberately a projection of the installation, not the installation: a + * consumer of this message renders a menu, and the owner of record, the + * succession set and the agent principal are no part of that. + * + * @generated from message saas.accounts.v1.SolutionEntitlement + */ +export type SolutionEntitlement = Message<"saas.accounts.v1.SolutionEntitlement"> & { + /** + * @generated from field: string installation_id = 2; + */ + installationId: string; + + /** + * The installation's authority-root scope node — the node the viewer's grant + * had to reach for this entitlement to exist. Carried so a consumer can say + * which boundary admitted a solution without a second lookup. + * + * @generated from field: string root_scope_node_id = 3; + */ + rootScopeNodeId: string; + + /** + * Whether the installation is healthy right now, resolved live from the same + * authority facts GetInstallation reports. + * + * A bool rather than a copy of InstallationHealth on purpose. A consumer of + * this message shows an unhealthy solution as unavailable and never renders + * the reason — an operator reads that from GetInstallation — so restating that + * enum here would be a second copy of a value set to keep in lockstep. This + * reduces it instead, by the one rule that survives the enum growing: anything + * that is not HEALTHY is not healthy. + * + * @generated from field: bool healthy = 4; + */ + healthy: boolean; + + /** + * The immutable solution target this entitlement is for — the key a consumer + * joins against its own registered set. + * + * A consumer resolves a route alias to a target through the host's applied + * presence state and then compares THIS id. That is what makes the alias-reuse + * transfer inexpressible: a replacement binding claiming a withdrawn alias + * resolves to its own target, which no installation of the predecessor names. + * + * The alias is deliberately absent. Carrying it here would let a consumer go + * back to joining on it, and it would be a copy of a value the presence + * snapshot already owns and may move. + * + * Deliberately no platform-administrator basis field. The scope union admits a + * platform super_admin for the action `read` alone, and using a solution is not + * a read, so that branch can never contribute an entitlement here — a field + * reporting it would be permanently false. A super_admin sees an organization's + * installations through GetInstallation / ListInstallations, not by appearing to + * be entitled to them. + * + * @generated from field: string target_id = 5; + */ + targetId: string; +}; + +/** + * Describes the message saas.accounts.v1.SolutionEntitlement. + * Use `create(SolutionEntitlementSchema)` to create a new message. + */ +export const SolutionEntitlementSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_entitlements, 0); + +/** + * @generated from message saas.accounts.v1.ListSolutionEntitlementsRequest + */ +export type ListSolutionEntitlementsRequest = Message<"saas.accounts.v1.ListSolutionEntitlementsRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * The subject whose entitlements to enumerate. A principal: a team's + * entitlements reach a member through the team grant, which the underlying + * union already resolves, so asking about a principal answers "everything this + * person may use" including what a team confers. + * + * @generated from field: string subject_id = 2; + */ + subjectId: string; + + /** + * Page bound. 0 -> server default; capped so an organization with many + * installations can never return an unbounded result in one call. + * + * @generated from field: int32 page_size = 3; + */ + pageSize: number; + + /** + * Opaque cursor from a previous response's next_page_token; empty for page one. + * + * @generated from field: string page_token = 4; + */ + pageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionEntitlementsRequest. + * Use `create(ListSolutionEntitlementsRequestSchema)` to create a new message. + */ +export const ListSolutionEntitlementsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_entitlements, 1); + +/** + * @generated from message saas.accounts.v1.ListSolutionEntitlementsResponse + */ +export type ListSolutionEntitlementsResponse = Message<"saas.accounts.v1.ListSolutionEntitlementsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.SolutionEntitlement entitlements = 1; + */ + entitlements: SolutionEntitlement[]; + + /** + * Set when more entitlements remain; pass it back as page_token. Empty on the + * last page. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionEntitlementsResponse. + * Use `create(ListSolutionEntitlementsResponseSchema)` to create a new message. + */ +export const ListSolutionEntitlementsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_entitlements, 2); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts new file mode 100644 index 000000000..c72d904eb --- /dev/null +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts @@ -0,0 +1,41 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/accounts/v1/solution_entitlements_service.proto (package saas.accounts.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { ListSolutionEntitlementsRequestSchema, ListSolutionEntitlementsResponseSchema } from "./solution_entitlements_pb"; +import { file_saas_accounts_v1_solution_entitlements } from "./solution_entitlements_pb"; +import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; + +/** + * Describes the file saas/accounts/v1/solution_entitlements_service.proto. + */ +export const file_saas_accounts_v1_solution_entitlements_service: GenFile = /*@__PURE__*/ + fileDesc("CjRzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX2VudGl0bGVtZW50c19zZXJ2aWNlLnByb3RvEhBzYWFzLmFjY291bnRzLnYxMroBChpTb2x1dGlvbkVudGl0bGVtZW50U2VydmljZRKbAQoYTGlzdFNvbHV0aW9uRW50aXRsZW1lbnRzEjEuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABYgZwcm90bzM", [file_saas_accounts_v1_solution_entitlements, file_saas_policy_v1_options]); + +/** + * SolutionEntitlementService answers what one viewer may use inside one + * organization. Internal-tier: it takes both the organization and the subject as + * request fields, so it is an authority oracle about an arbitrary principal and + * only a caller holding the cluster-internal credential may ask. The caller that + * does is the auth-gateway, naming the tenant and the viewer it projected from a + * verified identity — never values a browser supplied. + * + * @generated from service saas.accounts.v1.SolutionEntitlementService + */ +export const SolutionEntitlementService: GenService<{ + /** + * ListSolutionEntitlements enumerates the installed solutions a subject may + * use, reading the installation set and the scope grant + share union in one + * org transaction so the two can never describe different moments. + * + * @generated from rpc saas.accounts.v1.SolutionEntitlementService.ListSolutionEntitlements + */ + listSolutionEntitlements: { + methodKind: "unary"; + input: typeof ListSolutionEntitlementsRequestSchema; + output: typeof ListSolutionEntitlementsResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_saas_accounts_v1_solution_entitlements_service, 0); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_pb.ts index 9bb574404..4986912d9 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_pb.ts @@ -4,7 +4,6 @@ import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; -import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; import type { Timestamp } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; import type { Message } from "@bufbuild/protobuf"; @@ -13,13 +12,12 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/solution_registry.proto. */ export const file_saas_accounts_v1_solution_registry: GenFile = /*@__PURE__*/ - fileDesc("CihzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5LnByb3RvEhBzYWFzLmFjY291bnRzLnYxIo0BChdTb2x1dGlvbkZyb250ZW5kQmluZGluZxIQCghyZXZpc2lvbhgBIAEoAxIQCghtYW5pZmVzdBgCIAEoCRIYChBjb250cmFjdF92ZXJzaW9uGAMgASgJEjQKEGxlYXNlX2V4cGlyZXNfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIqMBChZTb2x1dGlvbkJhY2tlbmRCaW5kaW5nEhAKCHJldmlzaW9uGAEgASgDEhAKCHVwc3RyZWFtGAIgASgJEhUKDXNlcnZpY2VfYWxpYXMYAyABKAkSGAoQY29udHJhY3RfdmVyc2lvbhgEIAEoCRI0ChBsZWFzZV9leHBpcmVzX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK9AwoUU29sdXRpb25SZWdpc3RyYXRpb24SEwoLc29sdXRpb25faWQYASABKAkSEQoJcHVibGlzaGVyGAIgASgJEhAKCHJldmlzaW9uGAMgASgDEjwKBnN0YXR1cxgEIAEoDjIsLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25SZWdpc3RyYXRpb25TdGF0dXMSQAoIZnJvbnRlbmQYBSABKAsyKS5zYWFzLmFjY291bnRzLnYxLlNvbHV0aW9uRnJvbnRlbmRCaW5kaW5nSACIAQESPgoHYmFja2VuZBgGIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25CYWNrZW5kQmluZGluZ0gBiAEBEi4KCnVwZGF0ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjYKDXRvbWJzdG9uZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAKIAQESGAoQcnVudGltZV9ib3VuZGFyeRgJIAEoCUILCglfZnJvbnRlbmRCCgoIX2JhY2tlbmRCEAoOX3RvbWJzdG9uZWRfYXQiYQocU29sdXRpb25Gcm9udGVuZFJlZ2lzdHJhdGlvbhIdCghtYW5pZmVzdBgBIAEoCUILukgIcgYQAhiAgBASIgoQY29udHJhY3RfdmVyc2lvbhgCIAEoCUIIukgFcgMYgAEihQEKG1NvbHV0aW9uQmFja2VuZFJlZ2lzdHJhdGlvbhIfCgh1cHN0cmVhbRgBIAEoCUINukgKcggQARiAEIgBARIhCg1zZXJ2aWNlX2FsaWFzGAIgASgJQgq6SAdyBRABGIABEiIKEGNvbnRyYWN0X3ZlcnNpb24YAyABKAlCCLpIBXIDGIABIvsCCh5QdXRTb2x1dGlvblJlZ2lzdHJhdGlvblJlcXVlc3QSQwoLc29sdXRpb25faWQYASABKAlCLrpIK3IpEAEYgAEyIl5bYS16MC05XSg/OlthLXowLTlfLV0qW2EtejAtOV0pPyQSHQoJcHVibGlzaGVyGAIgASgJQgq6SAdyBRABGIACEicKEWV4cGVjdGVkX3JldmlzaW9uGAMgASgDQge6SAQiAiAASAGIAQESIQoNbGVhc2Vfc2Vjb25kcxgEIAEoDUIKukgHKgUYkBwoHhJCCghmcm9udGVuZBgFIAEoCzIuLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25Gcm9udGVuZFJlZ2lzdHJhdGlvbkgAEkAKB2JhY2tlbmQYBiABKAsyLS5zYWFzLmFjY291bnRzLnYxLlNvbHV0aW9uQmFja2VuZFJlZ2lzdHJhdGlvbkgAQg0KBGhhbGYSBbpIAggBQhQKEl9leHBlY3RlZF9yZXZpc2lvbiKDAQohRGVsZXRlU29sdXRpb25SZWdpc3RyYXRpb25SZXF1ZXN0Eh8KC3NvbHV0aW9uX2lkGAEgASgJQgq6SAdyBRABGIABEicKEWV4cGVjdGVkX3JldmlzaW9uGAIgASgDQge6SAQiAiAASACIAQFCFAoSX2V4cGVjdGVkX3JldmlzaW9uIj4KIExpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnNSZXF1ZXN0EhoKEmluY2x1ZGVfdG9tYnN0b25lZBgBIAEoCCJ9CiFMaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVzcG9uc2USPQoNcmVnaXN0cmF0aW9ucxgBIAMoCzImLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25SZWdpc3RyYXRpb24SGQoRcmVnaXN0cnlfcmV2aXNpb24YAiABKAMqowIKGlNvbHV0aW9uUmVnaXN0cmF0aW9uU3RhdHVzEiwKKFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABInCiNTT0xVVElPTl9SRUdJU1RSQVRJT05fU1RBVFVTX0FDVElWRRABEigKJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfUEVORElORxACEigKJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfRVhQSVJFRBADEi0KKVNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfSU5DT01QQVRJQkxFEAQSKwonU09MVVRJT05fUkVHSVNUUkFUSU9OX1NUQVRVU19UT01CU1RPTkVEEAViBnByb3RvMw", [file_buf_validate_validate, file_google_protobuf_timestamp]); + fileDesc("CihzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5LnByb3RvEhBzYWFzLmFjY291bnRzLnYxIm8KF1NvbHV0aW9uRnJvbnRlbmRCaW5kaW5nEhAKCHJldmlzaW9uGAEgASgDEhAKCG1hbmlmZXN0GAIgASgJEhgKEGNvbnRyYWN0X3ZlcnNpb24YAyABKAlKBAgEEAVSEGxlYXNlX2V4cGlyZXNfYXQihQEKFlNvbHV0aW9uQmFja2VuZEJpbmRpbmcSEAoIcmV2aXNpb24YASABKAMSEAoIdXBzdHJlYW0YAiABKAkSFQoNc2VydmljZV9hbGlhcxgDIAEoCRIYChBjb250cmFjdF92ZXJzaW9uGAQgASgJSgQIBRAGUhBsZWFzZV9leHBpcmVzX2F0IooEChRTb2x1dGlvblJlZ2lzdHJhdGlvbhITCgtzb2x1dGlvbl9pZBgBIAEoCRIRCglwdWJsaXNoZXIYAiABKAkSEAoIcmV2aXNpb24YAyABKAMSPAoGc3RhdHVzGAQgASgOMiwuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvblJlZ2lzdHJhdGlvblN0YXR1cxJACghmcm9udGVuZBgFIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25Gcm9udGVuZEJpbmRpbmdIAIgBARI+CgdiYWNrZW5kGAYgASgLMiguc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkJhY2tlbmRCaW5kaW5nSAGIAQESLgoKdXBkYXRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNgoNdG9tYnN0b25lZF9hdBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAogBARJACghkZWNsYXJlZBgKIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25EZWNsYXJlZEJpbmRpbmdIA4gBAUILCglfZnJvbnRlbmRCCgoIX2JhY2tlbmRCEAoOX3RvbWJzdG9uZWRfYXRCCwoJX2RlY2xhcmVkSgQICRAKUhBydW50aW1lX2JvdW5kYXJ5Ij4KIExpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnNSZXF1ZXN0EhoKEmluY2x1ZGVfdG9tYnN0b25lZBgBIAEoCCJ9CiFMaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVzcG9uc2USPQoNcmVnaXN0cmF0aW9ucxgBIAMoCzImLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25SZWdpc3RyYXRpb24SGQoRcmVnaXN0cnlfcmV2aXNpb24YAiABKAMimwEKF1NvbHV0aW9uRGVjbGFyZWRCaW5kaW5nEhIKCmJpbmRpbmdfaWQYASABKAkSEgoKZ2VuZXJhdGlvbhgCIAEoBBIPCgdyZWxlYXNlGAMgASgJEhEKCXRhcmdldF9pZBgEIAEoCRI0CgRraW5kGAUgASgOMiYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkRlY2xhcmVkS2luZCJ9Ch1Tb2x1dGlvbkhvc3RCaW5kaW5nR2VuZXJhdGlvbhISCgpnZW5lcmF0aW9uGAEgASgEEg4KBmRpZ2VzdBgCIAEoCRIQCghkb2N1bWVudBgDIAEoCRImCgJhdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAisgEKJFNvbHV0aW9uSG9zdEJpbmRpbmdBcHBsaWVkR2VuZXJhdGlvbhJDCgpnZW5lcmF0aW9uGAEgASgLMi8uc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkhvc3RCaW5kaW5nR2VuZXJhdGlvbhIPCgdyZW1vdmVkGAIgASgIEg4KBnJvdXRlcxgDIAMoCRITCgtzb2x1dGlvbl9pZBgEIAEoCRIPCgdyZWxlYXNlGAUgASgJIo4EChhTb2x1dGlvbkhvc3RCaW5kaW5nU3RhdGUSEgoKYmluZGluZ19pZBgBIAEoCRIXCg9ob3N0X2Nvb3JkaW5hdGUYAiABKAkSFgoOaG9zdF9jb21wb25lbnQYAyABKAkSRQoHZGVzaXJlZBgEIAEoCzIvLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25Ib3N0QmluZGluZ0dlbmVyYXRpb25IAIgBARJMCgdhcHBsaWVkGAUgASgLMjYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkhvc3RCaW5kaW5nQXBwbGllZEdlbmVyYXRpb25IAYgBARIaChJwZW5kaW5nX2dlbmVyYXRpb24YBiABKAQSFgoOcGVuZGluZ19yZWFzb24YByABKAkSNgoNcGVuZGluZ19zaW5jZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAogBARJBCgxyZWdpc3RyYXRpb24YCSABKAsyJi5zYWFzLmFjY291bnRzLnYxLlNvbHV0aW9uUmVnaXN0cmF0aW9uSAOIAQESLgoKdXBkYXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCCgoIX2Rlc2lyZWRCCgoIX2FwcGxpZWRCEAoOX3BlbmRpbmdfc2luY2VCDwoNX3JlZ2lzdHJhdGlvbiIhCh9MaXN0U29sdXRpb25Ib3N0QmluZGluZ3NSZXF1ZXN0ImAKIExpc3RTb2x1dGlvbkhvc3RCaW5kaW5nc1Jlc3BvbnNlEjwKCGJpbmRpbmdzGAEgAygLMiouc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkhvc3RCaW5kaW5nU3RhdGUqpQIKGlNvbHV0aW9uUmVnaXN0cmF0aW9uU3RhdHVzEiwKKFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABInCiNTT0xVVElPTl9SRUdJU1RSQVRJT05fU1RBVFVTX0FDVElWRRABEigKJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfUEVORElORxACEi0KKVNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfSU5DT01QQVRJQkxFEAQSKwonU09MVVRJT05fUkVHSVNUUkFUSU9OX1NUQVRVU19UT01CU1RPTkVEEAUiBAgDEAMqJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfRVhQSVJFRCqGAQoUU29sdXRpb25EZWNsYXJlZEtpbmQSJgoiU09MVVRJT05fREVDTEFSRURfS0lORF9VTlNQRUNJRklFRBAAEiMKH1NPTFVUSU9OX0RFQ0xBUkVEX0tJTkRfU09MVVRJT04QARIhCh1TT0xVVElPTl9ERUNMQVJFRF9LSU5EX01PRFVMRRACYgZwcm90bzM", [file_google_protobuf_timestamp]); /** * SolutionFrontendBinding is the stored frontend half. manifest is the document - * the frontend validated before handing it over; this service persists it - * verbatim and never reinterprets it, so the host keeps sole ownership of what - * a manifest may contain. + * the frontend validates while reading the projection; accounts stores it + * verbatim and keeps presence authority separate from runtime compatibility. * * @generated from message saas.accounts.v1.SolutionFrontendBinding */ @@ -38,11 +36,6 @@ export type SolutionFrontendBinding = Message<"saas.accounts.v1.SolutionFrontend * @generated from field: string contract_version = 3; */ contractVersion: string; - - /** - * @generated from field: google.protobuf.Timestamp lease_expires_at = 4; - */ - leaseExpiresAt?: Timestamp; }; /** @@ -78,11 +71,6 @@ export type SolutionBackendBinding = Message<"saas.accounts.v1.SolutionBackendBi * @generated from field: string contract_version = 4; */ contractVersion: string; - - /** - * @generated from field: google.protobuf.Timestamp lease_expires_at = 5; - */ - leaseExpiresAt?: Timestamp; }; /** @@ -106,8 +94,7 @@ export type SolutionRegistration = Message<"saas.accounts.v1.SolutionRegistratio solutionId: string; /** - * Owner of record. First claim binds it; a registration naming a different - * publisher is refused rather than overwriting. + * Canonical publisher attribution of the declared record. * * @generated from field: string publisher = 2; */ @@ -144,21 +131,11 @@ export type SolutionRegistration = Message<"saas.accounts.v1.SolutionRegistratio tombstonedAt?: Timestamp; /** - * The seed a solution's runtime boundary is derived from (issue #1015). + * The binding and immutable target that declared this record. * - * ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - * assigned by this service when the record is created and never again, and it - * is the only input to the per-organization boundary every Work Context - * minted for that solution is sealed under — so it is what stands between one - * solution's runs and another's. No registrant needs it: accounts derives and - * seals the boundary from the credential the solution already presents. - * - * The field is kept rather than removed so this promise is testable: a change - * that starts populating it fails a test instead of shipping. - * - * @generated from field: string runtime_boundary = 9; + * @generated from field: optional saas.accounts.v1.SolutionDeclaredBinding declared = 10; */ - runtimeBoundary: string; + declared?: SolutionDeclaredBinding; }; /** @@ -169,207 +146,323 @@ export const SolutionRegistrationSchema: GenMessage = /*@_ messageDesc(file_saas_accounts_v1_solution_registry, 2); /** - * SolutionFrontendRegistration is the caller-supplied frontend half. Revision - * and lease are assigned by the server, never proposed by the registrant. + * ListSolutionRegistrationsRequest reads the whole registry. There is one + * registry per deployment and it holds tens of records at most, so consumers + * rebuild their cache from a full snapshot rather than tailing a change feed. * - * @generated from message saas.accounts.v1.SolutionFrontendRegistration + * @generated from message saas.accounts.v1.ListSolutionRegistrationsRequest */ -export type SolutionFrontendRegistration = Message<"saas.accounts.v1.SolutionFrontendRegistration"> & { - /** - * @generated from field: string manifest = 1; - */ - manifest: string; - +export type ListSolutionRegistrationsRequest = Message<"saas.accounts.v1.ListSolutionRegistrationsRequest"> & { /** - * @generated from field: string contract_version = 2; + * Tombstones are omitted by default: a routing cache wants the live set. An + * operator diagnosing "was this deleted or never registered?" asks for them. + * + * @generated from field: bool include_tombstoned = 1; */ - contractVersion: string; + includeTombstoned: boolean; }; /** - * Describes the message saas.accounts.v1.SolutionFrontendRegistration. - * Use `create(SolutionFrontendRegistrationSchema)` to create a new message. + * Describes the message saas.accounts.v1.ListSolutionRegistrationsRequest. + * Use `create(ListSolutionRegistrationsRequestSchema)` to create a new message. */ -export const SolutionFrontendRegistrationSchema: GenMessage = /*@__PURE__*/ +export const ListSolutionRegistrationsRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 3); /** - * SolutionBackendRegistration is the caller-supplied backend half. The gateway - * has already constrained the upstream to a permitted host before it gets here; - * this service stores what it is told and does not re-derive routing policy. - * - * @generated from message saas.accounts.v1.SolutionBackendRegistration + * @generated from message saas.accounts.v1.ListSolutionRegistrationsResponse */ -export type SolutionBackendRegistration = Message<"saas.accounts.v1.SolutionBackendRegistration"> & { - /** - * @generated from field: string upstream = 1; - */ - upstream: string; - +export type ListSolutionRegistrationsResponse = Message<"saas.accounts.v1.ListSolutionRegistrationsResponse"> & { /** - * @generated from field: string service_alias = 2; + * @generated from field: repeated saas.accounts.v1.SolutionRegistration registrations = 1; */ - serviceAlias: string; + registrations: SolutionRegistration[]; /** - * @generated from field: string contract_version = 3; + * Highest revision in the registry, tombstones included. Two consumers that + * report the same registry_revision have converged on the same state. + * + * @generated from field: int64 registry_revision = 2; */ - contractVersion: string; + registryRevision: bigint; }; /** - * Describes the message saas.accounts.v1.SolutionBackendRegistration. - * Use `create(SolutionBackendRegistrationSchema)` to create a new message. + * Describes the message saas.accounts.v1.ListSolutionRegistrationsResponse. + * Use `create(ListSolutionRegistrationsResponseSchema)` to create a new message. */ -export const SolutionBackendRegistrationSchema: GenMessage = /*@__PURE__*/ +export const ListSolutionRegistrationsResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 4); /** - * PutSolutionRegistrationRequest writes exactly one half of one record. - * - * Writing a half whose content is byte-identical to what is stored is a lease - * renewal: it refreshes liveness and deliberately does not advance the - * revision, so a heartbeat never looks like a change to a consumer. Writing - * different content to a half that already exists requires expected_revision, - * which must equal the record's current revision — that is how a publisher - * still holding an older view is stopped from overwriting newer state, and how - * two concurrent updates serialize instead of racing. + * SolutionDeclaredBinding identifies the applied declaration and target. * - * @generated from message saas.accounts.v1.PutSolutionRegistrationRequest + * @generated from message saas.accounts.v1.SolutionDeclaredBinding */ -export type PutSolutionRegistrationRequest = Message<"saas.accounts.v1.PutSolutionRegistrationRequest"> & { +export type SolutionDeclaredBinding = Message<"saas.accounts.v1.SolutionDeclaredBinding"> & { /** - * @generated from field: string solution_id = 1; + * Stable ID of the deployment instance that declared this record. It is not + * the solution id: a binding ID identifies one instance and may carry + * characters a path segment may not. + * + * @generated from field: string binding_id = 1; */ - solutionId: string; + bindingId: string; /** - * @generated from field: string publisher = 2; + * The generation of that binding the host applied into this record. + * + * @generated from field: uint64 generation = 2; */ - publisher: string; + generation: bigint; + + /** + * publisher/name@version of the applied generation. + * + * @generated from field: string release = 3; + */ + release: string; /** - * Compare-and-swap guard against the record's current revision. Required to - * change an existing half, and the only way to re-register a tombstoned - * record: the caller must name the tombstone's own revision, which a delayed - * retry from a retired deployment does not hold. + * The immutable solution target this declaration opened — the identity an + * installation names. It is carried on the registration record so a consumer + * that was asked about a ROUTE ALIAS can resolve it to the target that + * currently serves it and compare identities. + * + * That resolution is the whole admission mechanism: a replacement binding + * which claimed a withdrawn alias resolves to its own target, which no + * installation of its predecessor names. A record with no declaration carries + * no target and is therefore admissible to nobody, which is the fail-closed + * answer and the direction of the cutover. * - * @generated from field: optional int64 expected_revision = 3; + * @generated from field: string target_id = 4; */ - expectedRevision?: bigint; + targetId: string; /** - * Requested liveness window for this half. The registrant renews within it. + * What the declaration declared the presence of. It decides which routing + * surface serves this record, so it is carried rather than inferred: a host + * that read the kind off the shape of a record — a publisher prefix, an alias + * convention — would be guessing at exactly the question that separates a + * surface with per-viewer admission from one without. * - * @generated from field: uint32 lease_seconds = 4; - */ - leaseSeconds: number; - - /** - * @generated from oneof saas.accounts.v1.PutSolutionRegistrationRequest.half - */ - half: { - /** - * @generated from field: saas.accounts.v1.SolutionFrontendRegistration frontend = 5; - */ - value: SolutionFrontendRegistration; - case: "frontend"; - } | { - /** - * @generated from field: saas.accounts.v1.SolutionBackendRegistration backend = 6; - */ - value: SolutionBackendRegistration; - case: "backend"; - } | { case: undefined; value?: undefined }; + * @generated from field: saas.accounts.v1.SolutionDeclaredKind kind = 5; + */ + kind: SolutionDeclaredKind; }; /** - * Describes the message saas.accounts.v1.PutSolutionRegistrationRequest. - * Use `create(PutSolutionRegistrationRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionDeclaredBinding. + * Use `create(SolutionDeclaredBindingSchema)` to create a new message. */ -export const PutSolutionRegistrationRequestSchema: GenMessage = /*@__PURE__*/ +export const SolutionDeclaredBindingSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 5); /** - * DeleteSolutionRegistrationRequest tombstones a record: both halves are - * cleared in the same statement that marks it deleted, so a reader that somehow - * ignored the tombstone still has no endpoint to route to. + * SolutionHostBindingGeneration is one generation of one document. The document + * is carried whole, canonically encoded, because "what is this host running" and + * "what has it been asked to run" are both answers an operator needs in full. * - * @generated from message saas.accounts.v1.DeleteSolutionRegistrationRequest + * @generated from message saas.accounts.v1.SolutionHostBindingGeneration */ -export type DeleteSolutionRegistrationRequest = Message<"saas.accounts.v1.DeleteSolutionRegistrationRequest"> & { +export type SolutionHostBindingGeneration = Message<"saas.accounts.v1.SolutionHostBindingGeneration"> & { /** - * @generated from field: string solution_id = 1; + * @generated from field: uint64 generation = 1; */ - solutionId: string; + generation: bigint; + + /** + * The canonical digest of the document, which is how a rewritten generation is + * told from a re-read of the applied one. + * + * @generated from field: string digest = 2; + */ + digest: string; + + /** + * @generated from field: string document = 3; + */ + document: string; /** - * @generated from field: optional int64 expected_revision = 2; + * @generated from field: google.protobuf.Timestamp at = 4; */ - expectedRevision?: bigint; + at?: Timestamp; }; /** - * Describes the message saas.accounts.v1.DeleteSolutionRegistrationRequest. - * Use `create(DeleteSolutionRegistrationRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionHostBindingGeneration. + * Use `create(SolutionHostBindingGenerationSchema)` to create a new message. */ -export const DeleteSolutionRegistrationRequestSchema: GenMessage = /*@__PURE__*/ +export const SolutionHostBindingGenerationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 6); /** - * ListSolutionRegistrationsRequest reads the whole registry. There is one - * registry per deployment and it holds tens of records at most, so consumers - * rebuild their cache from a full snapshot rather than tailing a change feed. + * SolutionHostBindingAppliedGeneration is the generation this host reconciled, + * and what it reconciled it into. * - * @generated from message saas.accounts.v1.ListSolutionRegistrationsRequest + * @generated from message saas.accounts.v1.SolutionHostBindingAppliedGeneration */ -export type ListSolutionRegistrationsRequest = Message<"saas.accounts.v1.ListSolutionRegistrationsRequest"> & { +export type SolutionHostBindingAppliedGeneration = Message<"saas.accounts.v1.SolutionHostBindingAppliedGeneration"> & { /** - * Tombstones are omitted by default: a routing cache wants the live set. An - * operator diagnosing "was this deleted or never registered?" asks for them. + * @generated from field: saas.accounts.v1.SolutionHostBindingGeneration generation = 1; + */ + generation?: SolutionHostBindingGeneration; + + /** + * A tombstone generation: the binding is declared absent. It is not an + * absence — the generation stays on the record, so a late or replayed older + * generation is still refused after a removal. * - * @generated from field: bool include_tombstoned = 1; + * @generated from field: bool removed = 2; */ - includeTombstoned: boolean; + removed: boolean; + + /** + * The route aliases the applied generation holds. A tombstone holds none. + * + * @generated from field: repeated string routes = 3; + */ + routes: string[]; + + /** + * The registration record this generation reconciled into. A tombstone keeps + * the key it withdrew, which is the only way it knows what to withdraw. + * + * @generated from field: string solution_id = 4; + */ + solutionId: string; + + /** + * @generated from field: string release = 5; + */ + release: string; }; /** - * Describes the message saas.accounts.v1.ListSolutionRegistrationsRequest. - * Use `create(ListSolutionRegistrationsRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionHostBindingAppliedGeneration. + * Use `create(SolutionHostBindingAppliedGenerationSchema)` to create a new message. */ -export const ListSolutionRegistrationsRequestSchema: GenMessage = /*@__PURE__*/ +export const SolutionHostBindingAppliedGenerationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 7); /** - * @generated from message saas.accounts.v1.ListSolutionRegistrationsResponse + * SolutionHostBindingState is one binding's whole state: desired, applied, and + * why they differ. Endpoint and manifest observations live on + * SolutionRegistration, separate from the declaration that grants presence. + * + * @generated from message saas.accounts.v1.SolutionHostBindingState */ -export type ListSolutionRegistrationsResponse = Message<"saas.accounts.v1.ListSolutionRegistrationsResponse"> & { +export type SolutionHostBindingState = Message<"saas.accounts.v1.SolutionHostBindingState"> & { /** - * @generated from field: repeated saas.accounts.v1.SolutionRegistration registrations = 1; + * @generated from field: string binding_id = 1; */ - registrations: SolutionRegistration[]; + bindingId: string; /** - * Highest revision in the registry, tombstones included. Two consumers that - * report the same registry_revision have converged on the same state. + * @generated from field: string host_coordinate = 2; + */ + hostCoordinate: string; + + /** + * @generated from field: string host_component = 3; + */ + hostComponent: string; + + /** + * The newest generation delivery has shown this host, whether or not it + * passed. * - * @generated from field: int64 registry_revision = 2; + * @generated from field: optional saas.accounts.v1.SolutionHostBindingGeneration desired = 4; */ - registryRevision: bigint; + desired?: SolutionHostBindingGeneration; + + /** + * The generation this host applied. Absent means nothing has ever passed, so + * nothing about this binding is being served. + * + * @generated from field: optional saas.accounts.v1.SolutionHostBindingAppliedGeneration applied = 5; + */ + applied?: SolutionHostBindingAppliedGeneration; + + /** + * The generation delivery is showing that this host has not applied, or 0 when + * desired and applied agree. + * + * @generated from field: uint64 pending_generation = 6; + */ + pendingGeneration: bigint; + + /** + * Why the pending generation was not applied. Empty when nothing is pending. + * + * @generated from field: string pending_reason = 7; + */ + pendingReason: string; + + /** + * @generated from field: optional google.protobuf.Timestamp pending_since = 8; + */ + pendingSince?: Timestamp; + + /** + * The registration record this binding declares, when it has applied one. It + * carries endpoint and manifest observations and their derived status. + * + * @generated from field: optional saas.accounts.v1.SolutionRegistration registration = 9; + */ + registration?: SolutionRegistration; + + /** + * @generated from field: google.protobuf.Timestamp updated_at = 10; + */ + updatedAt?: Timestamp; }; /** - * Describes the message saas.accounts.v1.ListSolutionRegistrationsResponse. - * Use `create(ListSolutionRegistrationsResponseSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionHostBindingState. + * Use `create(SolutionHostBindingStateSchema)` to create a new message. */ -export const ListSolutionRegistrationsResponseSchema: GenMessage = /*@__PURE__*/ +export const SolutionHostBindingStateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 8); /** - * SolutionRegistrationStatus is derived at read time from the stored record; it - * is never a stored column. It is what lets an operator tell a registration - * that was never completed from one whose publisher stopped renewing, and both - * from one that was deliberately removed. + * ListSolutionHostBindingsRequest reads every declared binding. There is one set + * per deployment and it holds tens of bindings at most, so a consumer rebuilds + * its view from a full snapshot. + * + * @generated from message saas.accounts.v1.ListSolutionHostBindingsRequest + */ +export type ListSolutionHostBindingsRequest = Message<"saas.accounts.v1.ListSolutionHostBindingsRequest"> & { +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionHostBindingsRequest. + * Use `create(ListSolutionHostBindingsRequestSchema)` to create a new message. + */ +export const ListSolutionHostBindingsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_registry, 9); + +/** + * @generated from message saas.accounts.v1.ListSolutionHostBindingsResponse + */ +export type ListSolutionHostBindingsResponse = Message<"saas.accounts.v1.ListSolutionHostBindingsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.SolutionHostBindingState bindings = 1; + */ + bindings: SolutionHostBindingState[]; +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionHostBindingsResponse. + * Use `create(ListSolutionHostBindingsResponseSchema)` to create a new message. + */ +export const ListSolutionHostBindingsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_registry, 10); + +/** + * SolutionRegistrationStatus is derived from declared state and complete, + * compatible frontend/backend observations, never from an expiring lease. * * @generated from enum saas.accounts.v1.SolutionRegistrationStatus */ @@ -380,7 +473,7 @@ export enum SolutionRegistrationStatus { UNSPECIFIED = 0, /** - * Both halves are present, their leases are live, and their declared contract + * Both halves are present and their declared contract * versions agree. This is the only status either surface serves. * * @generated from enum value: SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; @@ -388,21 +481,13 @@ export enum SolutionRegistrationStatus { ACTIVE = 1, /** - * A half has never registered. The record is durable but incomplete, so no + * A half has not been observed. The record is durable but incomplete, so no * page advertises it and the gateway has nothing to route to. * * @generated from enum value: SOLUTION_REGISTRATION_STATUS_PENDING = 2; */ PENDING = 2, - /** - * Both halves registered, but at least one lease lapsed: the deployment that - * owns it stopped renewing. The intent survives; the endpoint is not live. - * - * @generated from enum value: SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; - */ - EXPIRED = 3, - /** * The two halves declare different contract versions, so activating them * together would serve a page against a backend it was not built for. @@ -412,8 +497,7 @@ export enum SolutionRegistrationStatus { INCOMPATIBLE = 4, /** - * The registration was deregistered. The row survives as a tombstone so a - * delayed heartbeat from a retiring deployment cannot recreate it. + * An applied removal keeps the declaration as a tombstone. * * @generated from enum value: SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; */ @@ -425,3 +509,49 @@ export enum SolutionRegistrationStatus { */ export const SolutionRegistrationStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_saas_accounts_v1_solution_registry, 0); + +/** + * SolutionDeclaredKind is what a declaration declares the presence of. + * + * Core requires it on every presence document and refuses any other value, so + * every admitted declaration has exactly one of the two. The host used to drop + * it on reconcile, which left the registry unable to say whether a record was a + * solution or a module — and the two are routed on different surfaces with + * different admission, so a host that cannot tell them apart must either serve + * both from one surface or serve neither. + * + * UNSPECIFIED is not a third kind and is never a default. It is what a reader + * decodes from a writer that did not set the field, and both routing surfaces + * refuse it by name rather than picking the kind that happens to be more + * permissive. + * + * @generated from enum saas.accounts.v1.SolutionDeclaredKind + */ +export enum SolutionDeclaredKind { + /** + * @generated from enum value: SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * A composed solution instance, routed at /solutions//* with + * per-viewer installation admission. + * + * @generated from enum value: SOLUTION_DECLARED_KIND_SOLUTION = 1; + */ + SOLUTION = 1, + + /** + * One module instance, routed at /v1//* with the ordinary + * authenticated pipeline. + * + * @generated from enum value: SOLUTION_DECLARED_KIND_MODULE = 2; + */ + MODULE = 2, +} + +/** + * Describes the enum saas.accounts.v1.SolutionDeclaredKind. + */ +export const SolutionDeclaredKindSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_saas_accounts_v1_solution_registry, 1); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_service_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_service_pb.ts index 81eebc62b..305ee5940 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_service_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_service_pb.ts @@ -4,7 +4,7 @@ import type { GenFile, GenService } from "@bufbuild/protobuf/codegenv2"; import { fileDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { DeleteSolutionRegistrationRequestSchema, ListSolutionRegistrationsRequestSchema, ListSolutionRegistrationsResponseSchema, PutSolutionRegistrationRequestSchema, SolutionRegistrationSchema } from "./solution_registry_pb"; +import type { ListSolutionHostBindingsRequestSchema, ListSolutionHostBindingsResponseSchema, ListSolutionRegistrationsRequestSchema, ListSolutionRegistrationsResponseSchema } from "./solution_registry_pb"; import { file_saas_accounts_v1_solution_registry } from "./solution_registry_pb"; import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; @@ -12,37 +12,28 @@ import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; * Describes the file saas/accounts/v1/solution_registry_service.proto. */ export const file_saas_accounts_v1_solution_registry_service: GenFile = /*@__PURE__*/ - fileDesc("CjBzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5X3NlcnZpY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEyqAQKF1NvbHV0aW9uUmVnaXN0cnlTZXJ2aWNlErEBChdQdXRTb2x1dGlvblJlZ2lzdHJhdGlvbhIwLnNhYXMuYWNjb3VudHMudjEuUHV0U29sdXRpb25SZWdpc3RyYXRpb25SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvblJlZ2lzdHJhdGlvbiI8wvMYOAgDEAEwATomCiJzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl91cGRhdGVkEAJAAUgHUANYA2ABErcBChpEZWxldGVTb2x1dGlvblJlZ2lzdHJhdGlvbhIzLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU29sdXRpb25SZWdpc3RyYXRpb25SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvblJlZ2lzdHJhdGlvbiI8wvMYOAgDEAEwATomCiJzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl9kZWxldGVkEAJAAUgHUANYA2ABEp4BChlMaXN0U29sdXRpb25SZWdpc3RyYXRpb25zEjIuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvbHV0aW9uUmVnaXN0cmF0aW9uc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAFiBnByb3RvMw", [file_saas_accounts_v1_solution_registry, file_saas_policy_v1_options]); + fileDesc("CjBzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5X3NlcnZpY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEy2AIKF1NvbHV0aW9uUmVnaXN0cnlTZXJ2aWNlEpsBChhMaXN0U29sdXRpb25Ib3N0QmluZGluZ3MSMS5zYWFzLmFjY291bnRzLnYxLkxpc3RTb2x1dGlvbkhvc3RCaW5kaW5nc1JlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLkxpc3RTb2x1dGlvbkhvc3RCaW5kaW5nc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESngEKGUxpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnMSMi5zYWFzLmFjY291bnRzLnYxLkxpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnNSZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgAWIGcHJvdG8z", [file_saas_accounts_v1_solution_registry, file_saas_policy_v1_options]); /** - * SolutionRegistryService is the durable authority behind both self-registration - * surfaces. Every method is internal: the gateway is the only client, and it - * brokers the frontend's half as well as its own. + * Internal read service for declared solution state. The gateway is the client + * and brokers the registry projection to the frontend. * * @generated from service saas.accounts.v1.SolutionRegistryService */ export const SolutionRegistryService: GenService<{ /** - * PutSolutionRegistration writes or renews one half of a solution's - * registration under compare-and-swap revision semantics. + * ListSolutionHostBindings returns the declared bindings: what delivery has + * shown this host, what the host applied, and why a desired generation is not + * the applied one (issue #952). It is the answer to "is this solution missing, + * or declared and unhealthy?", which neither the registry snapshot nor a health + * probe can give on its own. * - * @generated from rpc saas.accounts.v1.SolutionRegistryService.PutSolutionRegistration + * @generated from rpc saas.accounts.v1.SolutionRegistryService.ListSolutionHostBindings */ - putSolutionRegistration: { + listSolutionHostBindings: { methodKind: "unary"; - input: typeof PutSolutionRegistrationRequestSchema; - output: typeof SolutionRegistrationSchema; - }, - /** - * DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - * a later heartbeat from the retired deployment cannot resurrect. - * - * @generated from rpc saas.accounts.v1.SolutionRegistryService.DeleteSolutionRegistration - */ - deleteSolutionRegistration: { - methodKind: "unary"; - input: typeof DeleteSolutionRegistrationRequestSchema; - output: typeof SolutionRegistrationSchema; + input: typeof ListSolutionHostBindingsRequestSchema; + output: typeof ListSolutionHostBindingsResponseSchema; }, /** * ListSolutionRegistrations returns the whole registry so a restarted or diff --git a/module/services/frontend/code/src/app/api/internal/solutions/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/internal/solutions/__tests__/route.test.ts index 733e9573f..96d0db782 100644 --- a/module/services/frontend/code/src/app/api/internal/solutions/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/internal/solutions/__tests__/route.test.ts @@ -1,4 +1,3 @@ -import { generateKeyPairSync, sign } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,8 +18,10 @@ const GATEWAY = "http://gateway.internal:8080"; // Registrations live in the durable registry behind the gateway rather than in // this process, so the suite stands one in for it. function fakeGateway() { - const stored = new Map(); - let revision = 0; + const stored = new Map([[MANIFEST.id, JSON.stringify(MANIFEST)]]); + // The cold cutover deleted the registry's writer surface, so the fake serves + // one fixed snapshot: nothing left in this suite can advance the revision. + const revision = 0; const respond = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, @@ -28,31 +29,35 @@ function fakeGateway() { }); return vi.fn(async (input: string | URL, init?: RequestInit) => { const url = new URL(String(input)); - if (url.pathname === "/v1/auth/.well-known/jwks.json") { - return new Response(CRED_JWKS, { status: 200 }); - } - if (url.pathname === "/solutions/_frontend") { - const body = JSON.parse(String(init?.body ?? "{}")); - stored.set(body.id, body.manifest); - revision += 1; - return respond({ ok: true, id: body.id, revision, status: "active" }); - } - if (url.pathname === "/solutions/_register" && init?.method === "DELETE") { - stored.delete(url.searchParams.get("id") ?? ""); - revision += 1; - return respond({ ok: true, revision }); - } if (url.pathname === "/solutions/_registry") { return respond({ revision, - leaseSeconds: 120, solutions: [...stored].map(([id, manifest]) => ({ + // The host stamps the target from the record's declaration; + // the fixture derives it from the alias so the entitlement + // answer below can agree with it, which is what the join + // needs. See projections.ts. + targetId: `target-${id}`, id, status: "active", manifest, })), }); } + // The viewer projection beside this one is authenticated and narrowed per + // viewer (#949); this file compares the two projections' FIELDS, so every + // registered solution is entitled here. + if (url.pathname === "/solutions/_entitlements") { + return respond({ + org: "org-acme", + viewer: "viewer-1", + solutions: [...stored.keys()].map((id) => ({ + targetId: `target-${id}`, + healthy: true, + scopeNodeId: `node-${id}`, + })), + }); + } return respond({ error: "unexpected" }, 500); }); } @@ -64,11 +69,7 @@ function resetRegistryCache() { } import { GET } from "@/app/api/internal/solutions/route"; -import { - DELETE, - POST, - GET as publicGET, -} from "@/app/api/solutions/register/route"; +import { GET as publicGET } from "@/app/api/solutions/route"; const TOKEN = "internal-test-token"; @@ -112,78 +113,16 @@ function internalRequest(token?: string): Request { } -const { publicKey: credPublicKey, privateKey: credPrivateKey } = - generateKeyPairSync("ed25519"); -const CRED_KEY_ID = "test-key"; -const CRED_JWKS = JSON.stringify({ - keys: [ - { - ...credPublicKey.export({ format: "jwk" }), - alg: "EdDSA", - use: "sig", - kid: CRED_KEY_ID, - }, - ], -}); -let credCounter = 0; - -/** Registration is credential-bound now; this file only needs a valid one. */ -function solutionCredential(solution = "audit"): string { - const b64 = (v: object) => - Buffer.from(JSON.stringify(v), "utf8").toString("base64url"); - const now = Math.floor(Date.now() / 1000); - const head = b64({ alg: "EdDSA", typ: "JWT", kid: CRED_KEY_ID }); - const payload = b64({ - iss: "saas-starter", - sub: `solution:${solution}`, - aud: ["solution-registration"], - solution, - iat: now, - exp: now + 300, - jti: `internal-jti-${credCounter++}`, - }); - return `${head}.${payload}.${sign(null, Buffer.from(`${head}.${payload}`, "utf8"), credPrivateKey).toString("base64url")}`; -} - -async function register(): Promise { - getWorkspaceSecret.mockReturnValue(TOKEN); - const response = await POST( - new Request("http://frontend/api/solutions/register", { - method: "POST", - headers: { - "content-type": "application/json", - "x-codefly-internal-token": TOKEN, - "x-codefly-solution-registration": solutionCredential(), - }, - body: JSON.stringify(MANIFEST), - }), - ); - expect(response.status).toBe(200); -} - describe("internal solution detail lookup", () => { beforeEach(() => { resetRegistryCache(); getEndpoints.mockReturnValue([ { service: "auth-gateway", name: "rest", address: `${GATEWAY}/rest` }, ]); - const g = globalThis as Record; - g.__solutionRegistrationJwks = undefined; - g.__solutionRegistrationJtis = undefined; vi.stubGlobal("fetch", fakeGateway()); }); - afterEach(async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - await DELETE( - new Request("http://frontend/api/solutions/register?id=audit", { - method: "DELETE", - headers: { - "x-codefly-internal-token": TOKEN, - "x-codefly-solution-registration": solutionCredential(), - }, - }), - ); + afterEach(() => { getWorkspaceSecret.mockReset(); vi.unstubAllGlobals(); resetRegistryCache(); @@ -205,7 +144,7 @@ describe("internal solution detail lookup", () => { }); it("serves the remote and backend detail to a trusted caller", async () => { - await register(); + getWorkspaceSecret.mockReturnValue(TOKEN); const response = await GET(internalRequest(TOKEN)); expect(response.status).toBe(200); @@ -224,10 +163,15 @@ describe("internal solution detail lookup", () => { expect(audit).not.toHaveProperty("dashboard"); }); - it("serves detail the public navigation projection withholds", async () => { - await register(); + it("serves detail the viewer navigation projection withholds", async () => { + getWorkspaceSecret.mockReturnValue(TOKEN); - const publicBody = (await publicGET().then((r) => r.json())) as { + const viewerRequest = new Request("http://frontend/api/solutions", { + headers: { authorization: "Bearer viewer-token" }, + }); + const publicBody = (await publicGET(viewerRequest).then((r) => + r.json(), + )) as { solutions: Array>; }; const publicAudit = publicBody.solutions.find((s) => s.id === "audit"); diff --git a/module/services/frontend/code/src/app/api/internal/solutions/route.ts b/module/services/frontend/code/src/app/api/internal/solutions/route.ts index 45372e8dc..bea33e0b9 100644 --- a/module/services/frontend/code/src/app/api/internal/solutions/route.ts +++ b/module/services/frontend/code/src/app/api/internal/solutions/route.ts @@ -7,7 +7,7 @@ export const runtime = "nodejs"; /** * Internal detail lookup for the registered solutions: the fields a caller * needs to resolve a solution's remote and its backend, which the public - * navigation projection (app/api/solutions/register) does not carry. + * navigation projection (app/api/solutions) does not carry. * * Its one caller today is the proxy's CSP derivation (src/proxy.ts), which runs * in a context whose module singletons are not shared with route handlers and diff --git a/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts deleted file mode 100644 index 060002875..000000000 --- a/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -import { generateKeyPairSync, sign } from "node:crypto"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("server-only", () => ({})); - -// The route reads the cluster-internal secret via the Codefly SDK, and the -// registry reaches the gateway through service discovery. vi.mock is hoisted -// above module init, so the stubs must be created with vi.hoisted. -const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({ - getWorkspaceSecret: - vi.fn<(name: string, key: string) => string | undefined>(), - getEndpoints: vi.fn<() => Array>>(() => []), -})); -vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints })); - -import { DELETE, GET, POST } from "@/app/api/solutions/register/route"; -import { - findSolution, - navProjection, - type SolutionManifest, -} from "@/solutions/registry"; - -const TOKEN = "internal-test-token"; -const { publicKey, privateKey } = generateKeyPairSync("ed25519"); -const KEY_ID = "test-key"; -const JWKS = JSON.stringify({ - keys: [ - { - ...publicKey.export({ format: "jwk" }), - alg: "EdDSA", - use: "sig", - kid: KEY_ID, - }, - ], -}); - -let jtiCounter = 0; - -function base64url(value: object): string { - return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); -} - -/** A credential accounts would mint: EdDSA, bound to one id and one publisher. */ -function credential( - solution = "audit", - subject = `solution:${solution}`, -): string { - const now = Math.floor(Date.now() / 1000); - const head = base64url({ alg: "EdDSA", typ: "JWT", kid: KEY_ID }); - const payload = base64url({ - iss: "saas-starter", - sub: subject, - aud: ["solution-registration"], - solution, - iat: now, - exp: now + 300, - jti: `jti-${jtiCounter++}`, - }); - const signature = sign( - null, - Buffer.from(`${head}.${payload}`, "utf8"), - privateKey, - ).toString("base64url"); - return `${head}.${payload}.${signature}`; -} -const GATEWAY = "http://gateway.internal:8080"; - -// A stand-in for the durable registry behind the gateway: enough of the wire -// contract for the route to be exercised end to end, with the revision counter -// that makes a write observable. -function fakeGateway() { - const stored = new Map(); - let revision = 0; - const respond = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - return vi.fn(async (input: string | URL, init?: RequestInit) => { - const url = new URL(String(input)); - if (url.pathname === "/v1/auth/.well-known/jwks.json") { - return new Response(JWKS, { status: 200 }); - } - if (url.pathname === "/solutions/_frontend") { - const body = JSON.parse(String(init?.body ?? "{}")); - stored.set(body.id, body.manifest); - revision += 1; - return respond({ ok: true, id: body.id, revision, status: "active" }); - } - if (url.pathname === "/solutions/_register" && init?.method === "DELETE") { - stored.delete(url.searchParams.get("id") ?? ""); - revision += 1; - return respond({ ok: true, revision }); - } - if (url.pathname === "/solutions/_registry") { - return respond({ - revision, - leaseSeconds: 120, - solutions: [...stored].map(([id, manifest]) => ({ - id, - status: "active", - manifest, - })), - }); - } - return respond({ error: "unexpected" }, 500); - }); -} - -/** - * A fetch stub that answers the JWKS lookup normally and drives every registry - * call to one outcome. The credential check and the registry write share a - * fetch, so a stub that only models the registry refuses the credential first - * and the test would pass for the wrong reason. - */ -function registryAnswering(response?: Response, onRegistry?: () => never) { - return vi.fn(async (input: string | URL) => { - if (new URL(String(input)).pathname === "/v1/auth/.well-known/jwks.json") { - return new Response(JWKS, { status: 200 }); - } - if (onRegistry) onRegistry(); - return response!.clone(); - }); -} - -// registry.ts caches its snapshot on globalThis so every Next module graph in a -// process shares one; drop it between tests or a stale snapshot leaks across. -function resetRegistryCache() { - const g = globalThis as Record; - g.__solutionSnapshot = null; - g.__solutionSnapshotInFlight = null; -} - -function manifestBody(id = "audit") { - return { - id, - nav: { title: "Audit", path: `/s/${id}` }, - frontend: { - type: "module-federation", - manifestUrl: "https://audit.internal/mf-manifest.json", - exposedModule: "./Page", - }, - }; -} - -function postRequest(body: unknown, token?: string): Request { - const headers: Record = { - "content-type": "application/json", - }; - if (token !== undefined) { - headers["x-codefly-internal-token"] = token; - const id = - typeof body === "object" && body !== null - ? String((body as { id?: unknown }).id ?? "audit") - : "audit"; - headers["x-codefly-solution-registration"] = credential(id); - } - return new Request("http://frontend/api/solutions/register", { - method: "POST", - headers, - body: JSON.stringify(body), - }); -} - -describe("solutions register route auth", () => { - beforeEach(() => { - resetRegistryCache(); - const g = globalThis as Record; - g.__solutionRegistrationJwks = undefined; - g.__solutionRegistrationJtis = undefined; - getEndpoints.mockReturnValue([ - { service: "auth-gateway", name: "rest", address: `${GATEWAY}/rest` }, - ]); - vi.stubGlobal("fetch", fakeGateway()); - }); - - afterEach(async () => { - getWorkspaceSecret.mockReset(); - // Clean any registration this suite added. - getWorkspaceSecret.mockReturnValue(TOKEN); - await DELETE( - new Request("http://frontend/api/solutions/register?id=audit", { - method: "DELETE", - headers: { "x-codefly-internal-token": TOKEN }, - }), - ); - getWorkspaceSecret.mockReset(); - vi.unstubAllGlobals(); - resetRegistryCache(); - }); - - it("rejects a POST with no internal token", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const res = await POST(postRequest(manifestBody())); - expect(res.status).toBe(401); - }); - - it("refuses a POST carrying the cluster-internal token but no credential", async () => { - // The shared token attests to no particular publisher, which is why it is - // no longer sufficient here: it is exactly what let any holder claim any id. - getWorkspaceSecret.mockReturnValue(TOKEN); - const res = await POST( - new Request("http://frontend/api/solutions/register", { - method: "POST", - headers: { - "content-type": "application/json", - "x-codefly-internal-token": TOKEN, - }, - body: JSON.stringify(manifestBody()), - }), - ); - expect(res.status).toBe(401); - }); - - it("refuses a POST whose credential names another solution", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const res = await POST( - new Request("http://frontend/api/solutions/register", { - method: "POST", - headers: { - "content-type": "application/json", - "x-codefly-solution-registration": credential("example-other"), - }, - body: JSON.stringify(manifestBody()), - }), - ); - expect(res.status).toBe(403); - }); - - it("accepts a POST with the correct internal token", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const res = await POST(postRequest(manifestBody(), TOKEN)); - expect(res.status).toBe(200); - // The revision the write landed at comes back, so a registrant can hold - // it and drive its own compare-and-swap next time. - await expect(res.json()).resolves.toMatchObject({ - ok: true, - id: "audit", - status: "active", - revision: 1, - }); - }); - - it("logs a heartbeat's state changes, never each beat", async () => { - // A registrant beats every few seconds for as long as it runs. The route - // says when the registration starts, is refused, and recovers — the beats - // in between are counted, not printed. - getWorkspaceSecret.mockReturnValue(TOKEN); - (globalThis as Record).__solutionRegistrationLog = - undefined; - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const renewing = vi.fn(async (input: string | URL) => { - if ( - new URL(String(input)).pathname === "/v1/auth/.well-known/jwks.json" - ) { - return new Response(JWKS, { status: 200 }); - } - // A renewal keeps the record's revision, as the durable registry does. - return Response.json({ - ok: true, - id: "audit", - revision: 4, - status: "active", - }); - }); - vi.stubGlobal("fetch", renewing); - for (let beat = 0; beat < 20; beat++) { - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(200); - } - expect(info).toHaveBeenCalledTimes(1); - expect(String(info.mock.calls[0]?.[0])).toContain( - '"audit" registered (revision 4, active)', - ); - - vi.stubGlobal( - "fetch", - registryAnswering(new Response("down", { status: 503 })), - ); - vi.spyOn(console, "error").mockImplementation(() => {}); - for (let beat = 0; beat < 5; beat++) { - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(503); - } - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain( - '"audit" refused 503 registry unavailable — was registered at revision 4 after 20 beats', - ); - - vi.stubGlobal("fetch", renewing); - await POST(postRequest(manifestBody(), TOKEN)); - expect(info).toHaveBeenCalledTimes(2); - expect(String(info.mock.calls[1]?.[0])).toContain( - "recovered from 503 registry unavailable after 5 beats", - ); - }); - - it("reports a key set outage and its end, not one line per beat and not silence", async () => { - // An unverified beat names no registrant, so holding it under a stand-in - // one could never recover: nothing unverified ever succeeds. The outage - // was one line, an hour ago, while every solution's own entry still read - // "registered" — and a refused credential arriving during the same - // outage alternated with it and printed on every beat. - getWorkspaceSecret.mockReturnValue(TOKEN); - const scope = globalThis as Record; - scope.__solutionRegistrationLog = undefined; - scope.__solutionRegistrationAuthority = undefined; - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(console, "error").mockImplementation(() => {}); - - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("upstream restarting", { status: 502 })), - ); - for (let beat = 0; beat < 5; beat++) { - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(503); - // A beat carrying no credential is judged without reaching the key - // set, so it is refused 401 inside the same window. Sharing one slot - // with the outage, the two alternated and printed on every beat. - expect((await POST(postRequest(manifestBody()))).status).toBe(401); - } - expect(warn).toHaveBeenCalledTimes(2); - expect(String(warn.mock.calls[0]?.[0])).toContain( - "the registration key set is unreachable", - ); - expect(String(warn.mock.calls[1]?.[0])).toContain( - "a beat presented a credential this host does not accept", - ); - - // The key set is probed on a backoff, so the recovering beat is the - // first one past that window rather than the first after the gateway - // returns. - vi.stubGlobal("fetch", fakeGateway()); - (globalThis as Record).__solutionRegistrationJwks = - undefined; - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(200); - expect(String(info.mock.calls[0]?.[0])).toContain( - "the registration key set is reachable again; it answered 503 for 5 beats", - ); - }); - - it("answers 503, not a refusal, when the key set it verifies against is unreachable", async () => { - // A restarting gateway is not a wrong credential. Told 401, a registrant - // goes looking for a provisioning or ownership fault that does not exist. - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.spyOn(console, "error").mockImplementation(() => {}); - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("upstream restarting", { status: 502 })), - ); - const res = await POST(postRequest(manifestBody(), TOKEN)); - expect(res.status).toBe(503); - expect(res.headers.get("retry-after")).toBeTruthy(); - await expect(res.json()).resolves.toEqual({ - error: "registration_authority_unavailable", - }); - }); - - it("still refuses a credential signed by a key the readable key set lacks", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const { privateKey: stranger } = generateKeyPairSync("ed25519"); - const now = Math.floor(Date.now() / 1000); - const head = base64url({ alg: "EdDSA", typ: "JWT", kid: "unknown-key" }); - const payload = base64url({ - iss: "saas-starter", - sub: "solution:audit", - aud: ["solution-registration"], - solution: "audit", - exp: now + 300, - jti: "jti-stranger", - }); - const signature = sign( - null, - Buffer.from(`${head}.${payload}`, "utf8"), - stranger, - ).toString("base64url"); - const res = await POST( - new Request("http://frontend/api/solutions/register", { - method: "POST", - headers: { - "content-type": "application/json", - "x-codefly-solution-registration": `${head}.${payload}.${signature}`, - }, - body: JSON.stringify(manifestBody()), - }), - ); - expect(res.status).toBe(401); - }); - - it("relays a registry conflict rather than reporting success", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal( - "fetch", - registryAnswering(new Response("conflict", { status: 409 })), - ); - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(409); - }); - - it("relays a foreign-publisher refusal", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal( - "fetch", - registryAnswering(new Response("forbidden", { status: 403 })), - ); - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(403); - }); - - it("relays a refused declaration as a manifest to change, not an outage", async () => { - // The gateway answers 422 registration_rejected only when accounts - // attached the structured declaration-rejection reason — a namespace not - // bound to this solution or held by another, a field an earlier - // declaration admitted and this one drops. Retrying the same manifest - // can never succeed, so it is 422, never 503, and the rule is relayed. - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal( - "fetch", - registryAnswering( - Response.json( - { - error: "registration_rejected", - detail: 'namespace "acme" is not bound to solution "acme"', - }, - { status: 422 }, - ), - ), - ); - const res = await POST(postRequest(manifestBody(), TOKEN)); - expect(res.status).toBe(422); - await expect(res.json()).resolves.toEqual({ - error: "registration_rejected", - detail: 'namespace "acme" is not bound to solution "acme"', - }); - }); - - it("keeps any other registry refusal an outage, not a rejected manifest", async () => { - // A plain 400 is a malformed write this host produced, and a 422 without - // the structured error is not the declaration refusal: neither tells the - // registrant to change its manifest. - for (const answer of [ - new Response("invalid registration", { status: 400 }), - Response.json({ error: "something_else" }, { status: 422 }), - ]) { - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal("fetch", registryAnswering(answer)); - const res = await POST(postRequest(manifestBody(), TOKEN)); - expect(res.status).toBe(503); - await expect(res.json()).resolves.toEqual({ - error: "registry_unavailable", - }); - } - }); - - it("forwards declared audit event types verbatim for the registry to admit", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const gateway = fakeGateway(); - vi.stubGlobal("fetch", gateway); - const body = manifestBody() as Record; - body.dashboard = { - events: [ - { - name: "item_created", - type: "acme.item.created", - fields: [ - { name: "score", kind: "number" }, - { name: "stage", kind: "enum", values: ["draft", "final"] }, - ], - }, - ], - metrics: [], - dashboards: [], - }; - const res = await POST(postRequest(body, TOKEN)); - expect(res.status).toBe(200); - const write = gateway.mock.calls.find( - ([input]) => new URL(String(input)).pathname === "/solutions/_frontend", - ); - const sent = JSON.parse(String(write?.[1]?.body)); - expect(JSON.parse(sent.manifest).dashboard.events[0].fields).toEqual([ - { name: "score", kind: "number" }, - { name: "stage", kind: "enum", values: ["draft", "final"] }, - ]); - }); - - it("refuses a malformed declaration before any registry write", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const gateway = fakeGateway(); - vi.stubGlobal("fetch", gateway); - const body = manifestBody() as Record; - body.dashboard = { - events: [{ name: "e", type: "saas.item.created", fields: [] }], - metrics: [], - dashboards: [], - }; - const res = await POST(postRequest(body, TOKEN)); - expect(res.status).toBe(422); - expect( - gateway.mock.calls.some( - ([input]) => new URL(String(input)).pathname === "/solutions/_frontend", - ), - ).toBe(false); - }); - - it("reports a registry outage instead of a phantom success", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal( - "fetch", - registryAnswering(undefined, () => { - throw new Error("gateway unreachable"); - }), - ); - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(503); - }); - - it("answers 503, not an empty list, when the registry cannot be read", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - vi.stubGlobal( - "fetch", - vi.fn(async () => { - throw new Error("gateway unreachable"); - }), - ); - resetRegistryCache(); - expect((await GET()).status).toBe(503); - }); - - it("rejects an authenticated POST carrying an unsafe manifest", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const bad = manifestBody("evil"); - (bad.frontend as Record).manifestUrl = - "javascript:alert(1)"; - const res = await POST(postRequest(bad, TOKEN)); - expect(res.status).toBe(422); - }); - - it("lets the browser GET the nav list without a token", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const res = await GET(); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toHaveProperty("solutions"); - }); - - it("projects the public nav list down to id and nav", async () => { - getWorkspaceSecret.mockReturnValue(TOKEN); - const body = manifestBody() as Record; - body.dashboard = { - events: [{ name: "login", type: "auth.login.v1" }], - metrics: [ - { - id: "logins", - kind: "source", - filter: { event: "login" }, - groupBy: "time", - bucket: "day", - aggregation: "count", - }, - ], - dashboards: [ - { - id: "activity", - layout: "grid", - widgets: [{ id: "logins", metric: "logins", visualization: "line" }], - }, - ], - }; - expect((await POST(postRequest(body, TOKEN))).status).toBe(200); - - const listed = (await GET().then((r) => r.json())) as { - solutions: Array>; - }; - const audit = listed.solutions.find((s) => s.id === "audit"); - expect(audit).toBeDefined(); - expect(audit?.nav).toMatchObject({ title: "Audit", path: "/s/audit" }); - // Everything the manifest carries beyond the nav entry is deployment - // topology: where the solution's code is served from, which backend - // fronts it, and its dashboard declaration. This response is readable by - // every signed-in browser, so it must carry none of it. - expect(Object.keys(audit ?? {}).sort()).toEqual(["id", "nav"]); - }); - - it("keeps a mutated nav projection out of the stored manifest", async () => { - // The projection copies the nav object rather than aliasing it, so a - // caller that mutates a projected value cannot reach the registry. - // - // This asserts against navProjection's own return value, NOT against a - // parsed GET body: `await response.json()` is a fresh object, so mutating - // it could never reach the registry however navProjection was written, - // and a test framed that way passes with the aliasing bug in place. - getWorkspaceSecret.mockReturnValue(TOKEN); - expect((await POST(postRequest(manifestBody(), TOKEN))).status).toBe(200); - - const stored = await findSolution("audit"); - expect(stored).not.toBeNull(); - expect(stored).not.toBe("unavailable"); - const projected = navProjection(stored as SolutionManifest); - projected.nav.title = "Tampered"; - - expect((await findSolution("audit")) as SolutionManifest).toMatchObject({ - nav: { title: "Audit" }, - }); - const listed = (await GET().then((r) => r.json())) as { - solutions: Array<{ id: string; nav: { title: string } }>; - }; - expect(listed.solutions.find((s) => s.id === "audit")?.nav.title).toBe( - "Audit", - ); - }); -}); diff --git a/module/services/frontend/code/src/app/api/solutions/register/route.ts b/module/services/frontend/code/src/app/api/solutions/register/route.ts deleted file mode 100644 index 7179da700..000000000 --- a/module/services/frontend/code/src/app/api/solutions/register/route.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { checkRuntimeCompatibility } from "@/solutions/compatibility"; -import { - consumeRegistrationToken, - SOLUTION_REGISTRATION_HEADER, - type SolutionRegistrationClaims, - verifySolutionRegistration, -} from "@/solutions/registration-authority"; -import { - observeAuthorityReachable, - observeAuthorityRefusal, - observeRegistrationBeat, - observeRegistrationRemoved, - type RegistrationRefusal, -} from "@/solutions/registration-log"; -import { - loadSolutions, - navProjection, - parseManifest, - registerSolution, - type SolutionWriteResult, - unregisterSolution, -} from "@/solutions/registry"; - -export const dynamic = "force-dynamic"; -export const runtime = "nodejs"; - -/** - * Self-registration endpoint. A solution POSTs its manifest here on startup so - * the host learns about it at runtime. This is generic: the host validates the - * shape and stores it, never referencing any specific solution. - * - * Registration mutates what the nav renders and what the solution route loads - * as a Module Federation remote — code that then runs in the host origin with - * the viewer's credentials — so it is authorized on the publisher's OWN signed, - * solution-bound credential, the same one the gateway requires, rather than on - * a secret every component in the mesh holds. A credential for one solution - * confers no authority over another. - * - * That credential is the whole boundary on the public front door, and nothing - * below the application can narrow it: `frontend/http` is a public module - * export, so this path shares TCP 3000 with every browser-facing page, a - * NetworkPolicy selects pods and ports rather than paths, and the mesh's L7 - * policy is enforced by a waypoint that never sees ingress-originated traffic. - * POST/DELETE here are declared as `internal_http_routes` in the topology - * binding and denied in the mesh from every in-mesh principal outside the - * frontend's declared callers, which contains lateral use of a leaked - * credential but does not gate the front door. See - * module/DEPLOYMENT_TOPOLOGY.md, "Cluster-internal HTTP routes". - */ - -/** - * Verify the presented credential and burn its single use. The jti is burned - * per process, so the same credential still authorizes the gateway's own check - * on the write this route forwards. - * - * Answers a Response when the credential is not accepted: `401` when it was - * judged and refused, `503` when this host could not reach the key set to judge - * it. A registrant told `401` for the second goes looking for a provisioning or - * ownership fault that does not exist; told `503`, it retries. - */ -async function authorize( - request: Request, -): Promise { - const verdict = await verifySolutionRegistration( - request.headers.get(SOLUTION_REGISTRATION_HEADER), - ); - // Recorded here, where the verdict is known, and not as a registrant state: - // a beat that did not verify names no registrant this host believes. The - // key set being unreachable lasts many beats and is one condition, not one - // per beat — and a beat that DOES verify is the only evidence the key set - // is reachable again, which is why the recovery is recorded here too. - if (verdict === "unavailable") { - observeAuthorityRefusal("unreachable"); - return Response.json( - { error: "registration_authority_unavailable" }, - { status: 503, headers: { "retry-after": "5" } }, - ); - } - if (verdict === "invalid" || !consumeRegistrationToken(verdict)) { - observeAuthorityRefusal("refused"); - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - observeAuthorityReachable(); - return verdict; -} - -/** - * A registration beat. The heartbeat that calls this is logged by - * {@link observeRegistrationBeat} at its state changes only — never per beat — - * and the dev server's own per-request line for this path is switched off in - * next.config.mjs, so the two together print nothing for a beat that finds the - * registration as it left it. - */ -export async function POST(request: Request): Promise { - const answer = await registerBeat(request); - switch (answer.outcome) { - case "unverified": - // The credential never verified, so there is no registrant to - // attribute this beat to; authorize() already recorded it against - // the credential check itself. - break; - case "registered": { - const body = (await answer.response.clone().json()) as { - revision: number; - status: string; - }; - observeRegistrationBeat(answer.solution, { - ok: true, - revision: body.revision, - status: body.status, - }); - break; - } - default: - observeRegistrationBeat(answer.solution, { - ok: false, - httpStatus: answer.response.status, - reason: answer.reason, - detail: answer.detail, - }); - } - return answer.response; -} - -/** - * One beat's answer. - * - * Three outcomes rather than a response plus optional fields: a beat whose - * credential did not verify has no registrant to name, and a refusal always - * has a reason. Spelling that out is what keeps the caller from needing a - * default for a reason that is never actually absent. - */ -type BeatAnswer = - | { outcome: "unverified"; response: Response } - | { outcome: "registered"; solution: string; response: Response } - | { - outcome: "refused"; - solution: string; - response: Response; - reason: RegistrationRefusal; - detail?: string; - }; - -async function registerBeat(request: Request): Promise { - const claims = await authorize(request); - if (claims instanceof Response) { - return { outcome: "unverified", response: claims }; - } - const solution = claims.solution; - let body: unknown; - try { - body = await request.json(); - } catch { - return { - outcome: "refused", - solution, - response: Response.json({ error: "invalid_json" }, { status: 400 }), - reason: "invalid_json", - }; - } - const manifest = parseManifest(body); - if (!manifest) { - return { - outcome: "refused", - solution, - response: Response.json({ error: "invalid_manifest" }, { status: 422 }), - reason: "invalid_manifest", - }; - } - if (manifest.id !== claims.solution) { - return { - outcome: "refused", - solution, - response: Response.json( - { error: "solution_not_authorized" }, - { status: 403 }, - ), - reason: "solution_not_authorized", - detail: `manifest names "${manifest.id}"`, - }; - } - // Compatibility is enforced BEFORE the write, so an incompatible remote never - // reaches a browser and an existing, working registration keeps serving. The - // reasons ride the response and the server log: a registrant told only "409" - // would have nothing to act on. - const verdict = checkRuntimeCompatibility(manifest); - if (!verdict.compatible) { - return { - outcome: "refused", - solution, - response: Response.json( - { error: "incompatible_runtime", reasons: verdict.reasons }, - { status: 409 }, - ), - reason: "incompatible_runtime", - detail: verdict.reasons.join("; "), - }; - } - // A solution whose registration was deregistered has to say so to come back: - // an ordinary retry from a retiring deployment must not resurrect what an - // operator removed. - const reactivate = - typeof body === "object" && - body !== null && - (body as { reactivate?: unknown }).reactivate === true; - const result = await registerSolution(manifest, { - reactivate, - credential: request.headers.get(SOLUTION_REGISTRATION_HEADER) ?? undefined, - }); - if (!result.ok) { - return { - outcome: "refused", - solution, - response: writeFailure(result), - reason: `registry ${result.reason}`, - }; - } - return { - outcome: "registered", - solution, - response: Response.json({ - ok: true, - id: manifest.id, - revision: result.revision, - status: result.status, - }), - }; -} - -/** - * Registration is a write to a shared, versioned record, so it can fail in ways - * a caller must tell apart: `conflict` means re-read and retry, `forbidden` - * means the id belongs to someone else, `rejected` means the registry will not - * admit this manifest — change it, do not retry it — and `unavailable` means - * back off. Answering 200 for any of these would let a solution believe it is - * serving when it is not — the exact incoherence this registry exists to - * prevent. - */ -function writeFailure( - failure: Extract, -): Response { - switch (failure.reason) { - case "conflict": - return Response.json({ error: "revision_conflict" }, { status: 409 }); - case "forbidden": - return Response.json( - { error: "not_registration_owner" }, - { status: 403 }, - ); - case "rejected": - // The declared audit event types are the one part of a manifest only - // the registry can judge (whether the namespace is bound to this - // solution and free, whether a changed field set only grows), so - // this is the same class of answer as invalid_manifest: the - // registrant must change what it sends. `detail` names the rule. - return Response.json( - { - error: "registration_rejected", - ...(failure.detail ? { detail: failure.detail } : {}), - }, - { status: 422 }, - ); - default: - return Response.json({ error: "registry_unavailable" }, { status: 503 }); - } -} - -export async function DELETE(request: Request): Promise { - const claims = await authorize(request); - if (claims instanceof Response) { - return claims; - } - const id = new URL(request.url).searchParams.get("id"); - if (!id) { - return Response.json({ error: "missing_id" }, { status: 400 }); - } - if (id !== claims.solution) { - return Response.json({ error: "solution_not_authorized" }, { status: 403 }); - } - const result = await unregisterSolution( - id, - request.headers.get(SOLUTION_REGISTRATION_HEADER) ?? undefined, - ); - if (!result.ok) { - return writeFailure(result); - } - observeRegistrationRemoved(id, result.revision); - return Response.json({ ok: true, revision: result.revision }); -} - -// GET is the public navigation projection: the id and the nav entry the browser -// polls to render the Solutions menu, and nothing else. It is deliberately not -// gated on the internal token — every signed-in browser needs it — so it must -// carry no field a browser does not render. Where a solution's code is served -// from, which backend service fronts it, and its dashboard declaration are -// deployment topology; they are served by the internal detail lookup -// (app/api/internal/solutions) to callers holding the cluster-internal token. -export async function GET(): Promise { - const registered = await loadSolutions(); - // An empty registry and an unreadable one must not render the same: the - // first correctly shows no solutions, the second would silently empty a - // working navigation. - if (registered === "unavailable") { - return Response.json({ error: "registry_unavailable" }, { status: 503 }); - } - return Response.json({ solutions: registered.map(navProjection) }); -} diff --git a/module/services/frontend/code/src/app/api/solutions/route.ts b/module/services/frontend/code/src/app/api/solutions/route.ts new file mode 100644 index 000000000..51a4771fd --- /dev/null +++ b/module/services/frontend/code/src/app/api/solutions/route.ts @@ -0,0 +1,34 @@ +import { entitlementFailureResponse, isEntitlementFailure, viewerEntitlements } from "@/solutions/entitlements"; +import { cachedProjection, entitledSolutions, navProjection } from "@/solutions/projections"; +import { loadSolutionsWithRevision } from "@/solutions/registry"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +// Read the viewer's navigation from declared presence and verified entitlements. +export async function GET(request: Request): Promise { + const entitlements = await viewerEntitlements(request); + if (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); + } + const registered = await loadSolutionsWithRevision(); + // An empty registry and an unreadable one must not render the same: the + // first correctly shows no solutions, the second would silently empty a + // working navigation. + if (registered === "unavailable") { + return Response.json({ error: "registry_unavailable" }, { status: 503 }); + } + // The nav projection has no client kind of its own — this host's own web app is + // the one consumer — so the kind component of the cache key is a constant + // naming it, kept distinct from any registered client kind. + const solutions = cachedProjection( + entitlements, + "\u0000host-nav", + registered.revision, + () => + entitledSolutions(registered.solutions, entitlements).map( + ({ manifest, entitlement }) => navProjection(manifest, entitlement), + ), + ); + return Response.json({ solutions }); +} diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts index 971939142..ac116a278 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/__tests__/route.test.ts @@ -33,18 +33,82 @@ function manifest( }); } -/** A gateway serving one registry snapshot, the only call this route makes. */ +interface EntitlementAnswer { + /** Solution ids the viewer may use; defaults to every registered one. */ + usable?: string[]; + /** Ids whose installation is unhealthy — entitled, but not routable. */ + unhealthy?: string[]; + /** Answer the entitlement read with this status instead of 200. */ + status?: number; + /** The gateway's own refusal name (X-Codefly-Entitlement-Refusal), if any. */ + refusal?: string; + org?: string; + viewer?: string; +} + +/** + * A gateway serving the two reads this route makes: the registry snapshot, and + * the per-viewer entitlement projection that narrows it (#949). + */ function gatewayServing( - solutions: Array<{ id: string; status: string; manifest: string }>, + solutions: Array<{ id: string; status: string; manifest?: string }>, + entitlements: EntitlementAnswer = {}, ) { - return vi.fn(async (input: string | URL) => { - if (new URL(String(input)).pathname !== "/solutions/_registry") { + return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const target = + input instanceof Request ? input.url : String(input as string | URL); + const path = new URL(target).pathname; + if (path === "/solutions/_entitlements") { + if (entitlements.status !== undefined && entitlements.status !== 200) { + const headers = new Headers(); + if (entitlements.refusal) { + headers.set("X-Codefly-Entitlement-Refusal", entitlements.refusal); + } + return new Response(JSON.stringify({ error: "refused" }), { + status: entitlements.status, + headers, + }); + } + // The gateway answers 401 itself when no credential was forwarded; this + // stub stands in for ext_authz making that call. + const headers = new Headers(init?.headers); + if (!headers.get("authorization")) { + return new Response(JSON.stringify({ error: "unauthenticated" }), { + status: 401, + }); + } + const usable = entitlements.usable ?? solutions.map((s) => s.id); + return new Response( + JSON.stringify({ + org: entitlements.org ?? "org-acme", + viewer: entitlements.viewer ?? "viewer-1", + solutions: usable.map((id) => ({ + targetId: `target-${id}`, + healthy: !(entitlements.unhealthy ?? []).includes(id), + scopeNodeId: `node-${id}`, + })), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (path !== "/solutions/_registry") { return new Response(JSON.stringify({ error: "unexpected" }), { status: 500, }); } return new Response( - JSON.stringify({ revision: 3, leaseSeconds: 120, solutions }), + JSON.stringify({ + revision: 3, + // The host stamps each record's target from its declaration, and + // the entitlement answer above derives the same value from the + // alias — which is what lets the projection join them. A fixture + // that left this out would produce manifests with no target, and + // every projection would be legitimately empty. + solutions: solutions.map((entry) => ({ + targetId: `target-${entry.id}`, + ...entry, + })), + }), { status: 200, headers: { "content-type": "application/json" } }, ); }); @@ -52,14 +116,22 @@ function gatewayServing( // registry.ts caches its snapshot on globalThis so every Next module graph in a // process shares one; drop it between tests or a stale snapshot leaks across. +// The projection cache is anchored there for the same reason and is keyed on the +// authority revision, so it must be dropped too — otherwise one test's menu +// answers another's viewer. function resetRegistryCache() { const g = globalThis as Record; g.__solutionSnapshot = null; g.__solutionSnapshotInFlight = null; + g.__solutionProjectionCache = new Map(); } -function request(query: string): Request { - return new Request(`http://frontend/api/solutions/surfaces${query}`); +function request(query: string, credential = "Bearer viewer-token"): Request { + const headers = new Headers(); + if (credential) headers.set("authorization", credential); + return new Request(`http://frontend/api/solutions/surfaces${query}`, { + headers, + }); } const WORD_SURFACE = { @@ -114,6 +186,7 @@ describe("solutions surfaces route", () => { id: "audit", title: "AUDIT", origin: "https://audit.internal", + available: true, surfaces: [WORD_SURFACE], }, ], @@ -184,9 +257,9 @@ describe("solutions surfaces route", () => { }); it("carries no deployment topology", async () => { - // The nav projection beside this one is ungated for the same reason and - // under the same rule: a public projection ships what a caller renders, - // never where a solution's code or backend lives. + // The nav projection beside this one narrows the same way and obeys the + // same rule: a viewer-facing projection ships what a caller renders, never + // where a solution's code or backend lives. vi.stubGlobal( "fetch", gatewayServing([ @@ -206,6 +279,7 @@ describe("solutions surfaces route", () => { solutions: Array>; }; expect(Object.keys(parsed.solutions[0] ?? {}).sort()).toEqual([ + "available", "id", "origin", "surfaces", @@ -232,9 +306,31 @@ describe("solutions surfaces route", () => { it("answers 503 rather than an empty list when the registry is unreadable", async () => { // An empty answer would silently retract every surface a client is // showing, which is indistinguishable from the solutions withdrawing them. + // The entitlement read succeeds here so the 503 is attributable to the + // registry and not to the authority beside it. vi.stubGlobal( "fetch", - vi.fn(async () => { + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const target = + input instanceof Request ? input.url : String(input as string | URL); + if (new URL(target).pathname === "/solutions/_entitlements") { + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer viewer-token"); + return new Response( + JSON.stringify({ + org: "org-acme", + viewer: "viewer-1", + solutions: [ + { + targetId: "target-audit", + healthy: true, + scopeNodeId: "node-audit", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } throw new Error("unreachable"); }), ); @@ -242,4 +338,169 @@ describe("solutions surfaces route", () => { expect(res.status).toBe(503); expect(await res.json()).toEqual({ error: "registry_unavailable" }); }); + + // ----------------------------------------------------------------------- + // Per-organization, per-viewer narrowing (issue #949) + // ----------------------------------------------------------------------- + + const audit = { + id: "audit", + status: "active", + manifest: manifest("audit", [WORD_SURFACE]), + }; + const ledger = { + id: "ledger", + status: "active", + manifest: manifest("ledger", [WORD_SURFACE]), + }; + + it("answers 401, not an empty list, to an unauthenticated caller", async () => { + // [] would make "you are not signed in" and "your organization installed + // nothing" the same answer, and a client told the second audits its + // registration instead of its session. + vi.stubGlobal("fetch", gatewayServing([audit])); + const res = await GET(request("?client=word", "")); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "unauthenticated" }); + }); + + it("refuses before reading the registry, so it is no availability oracle", async () => { + // An unauthenticated caller must not be able to learn whether the registry + // is healthy by comparing 401 against 503. + const fetchMock = gatewayServing([audit]); + vi.stubGlobal("fetch", fetchMock); + await GET(request("?client=word", "")); + const paths = fetchMock.mock.calls.map( + (call) => + new URL( + call[0] instanceof Request + ? call[0].url + : String(call[0] as string | URL), + ).pathname, + ); + expect(paths).toEqual(["/solutions/_entitlements"]); + }); + + it("answers 403 when the session carries no organization", async () => { + vi.stubGlobal( + "fetch", + gatewayServing([audit], { status: 403, refusal: "no-organization" }), + ); + const res = await GET(request("?client=word")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "no_organization" }); + }); + + it("answers 403 forbidden, not no_organization, for an ext_authz refusal", async () => { + // Only the gateway's own named refusal means "no organization"; a bare 403 + // is a verdict on the user's credential and must not be relabelled. + vi.stubGlobal("fetch", gatewayServing([audit], { status: 403 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "forbidden" }); + }); + + it("answers 503, not 401, when the gateway refuses this frontend's internal credential", async () => { + // A deployment fault on the frontend's own credential. Relayed as 401 it + // would tell every signed-in viewer to re-authenticate, and make the menu's + // authedFetch rotate the refresh token on every poll. + vi.stubGlobal( + "fetch", + gatewayServing([audit], { status: 401, refusal: "internal-credential" }), + ); + const res = await GET(request("?client=word")); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "authority_unavailable" }); + }); + + it("relays 429 when the organization spent its read budget", async () => { + vi.stubGlobal("fetch", gatewayServing([audit], { status: 429 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: "rate_limited" }); + }); + + it("answers 503, never an empty list, when the authority cannot answer", async () => { + vi.stubGlobal("fetch", gatewayServing([audit], { status: 500 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "authority_unavailable" }); + }); + + it("hides a deployed solution the organization has not installed", async () => { + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { usable: ["audit"] }), + ); + const body = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(body.solutions.map((entry) => entry.id)).toEqual(["audit"]); + }); + + it("gives two viewers in one organization different surface lists", async () => { + // The acceptance case: same deployment, same registered set, different + // grants — so the answer is a function of the viewer, not the deployment. + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { + viewer: "viewer-in-reviewers", + usable: ["audit"], + }), + ); + const first = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(first.solutions.map((entry) => entry.id)).toEqual(["audit"]); + + resetRegistryCache(); + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { + viewer: "viewer-in-clerks", + usable: ["ledger"], + }), + ); + const second = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(second.solutions.map((entry) => entry.id)).toEqual(["ledger"]); + }); + + it("narrows after a revocation without the registry changing", async () => { + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { usable: ["audit", "ledger"] }), + ); + const before = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(before.solutions.map((entry) => entry.id)).toEqual([ + "audit", + "ledger", + ]); + + // The grant on `ledger` is revoked. Only the authority answer changes — + // and because the projection cache is keyed on a digest of it, the cached + // menu cannot survive the grant that justified it. + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { usable: ["audit"] }), + ); + const after = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(after.solutions.map((entry) => entry.id)).toEqual(["audit"]); + }); + + it("keeps an unhealthy installation listed and marks it unavailable", async () => { + vi.stubGlobal("fetch", gatewayServing([audit], { unhealthy: ["audit"] })); + const body = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string; available: boolean }>; + }; + // Listed, because the org installed it and the viewer was granted it. + // Unavailable, because it must not be routed as though it were serving. + expect(body.solutions).toHaveLength(1); + expect(body.solutions[0]?.available).toBe(false); + }); }); diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts index b5785d0fc..0d9ad6231 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts @@ -1,28 +1,48 @@ import { requestPublicOrigin } from "@/lib/public-origin"; import { - isClientKind, - loadSolutions, + entitlementFailureResponse, + isEntitlementFailure, + viewerEntitlements, +} from "@/solutions/entitlements"; +import { + cachedProjection, + entitledSolutions, surfacesProjection, +} from "@/solutions/projections"; +import { + isClientKind, + loadSolutionsWithRevision, + type SolutionClientSurfaces, } from "@/solutions/registry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; /** - * The client-facing projection of what registered solutions offer inside one - * kind of client — a word processor, a spreadsheet — rather than inside - * this host's own pages. + * The client-facing projection of what the CALLING VIEWER may use inside one + * kind of client — a word processor, a spreadsheet — rather than inside this + * host's own pages. * * A client that cannot ask this has to ship a table of which solution offers * what, which goes stale the moment a solution is registered, withdrawn, or * changes what it offers. Asking for its own kind gets it exactly what applies * and nothing else. * - * Like the navigation projection beside it, this is deliberately ungated, and - * like it this carries no manifest path and no backend service. It does carry - * each solution's origin, because a surface module is a path the client fetches - * against that origin: withholding it would not keep the origin from a caller - * that can use a surface at all, only make the answer unusable. + * This route is authenticated (issue #949). It used to be deliberately ungated, + * and answered every caller the same deployment-wide set — so the list was a + * function of what is *registered*, not of what the caller may *use*. Narrowing it + * needs a verified organization and viewer, which exist only behind the gateway's + * `ext_authz`; see solutions/entitlements.ts for why nothing here may derive them. + * + * An unauthenticated caller therefore gets 401, not an empty list. `[]` would make + * "you are not signed in" and "your organization installed nothing" the same + * answer, and a client told the second goes looking at its registration instead of + * its session. + * + * It still carries each solution's origin, because a surface module is a path the + * client fetches against that origin: withholding it would not keep the origin + * from a caller that can use a surface at all, only make the answer unusable. The + * manifest path and backend service are still withheld. */ export async function GET(request: Request): Promise { // The kind is required, never defaulted to "all": a projection that widens @@ -39,7 +59,13 @@ export async function GET(request: Request): Promise { if (!isClientKind(client)) { return Response.json({ error: "invalid_client" }, { status: 400 }); } - const registered = await loadSolutions(); + // Asked before the registry is read: a caller with no standing to see any + // projection must not be able to probe whether the registry is healthy. + const entitlements = await viewerEntitlements(request); + if (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); + } + const registered = await loadSolutionsWithRevision(); // An empty registry and an unreadable one must not render the same: the // first correctly offers nothing, the second would silently retract every // surface a client is currently showing. @@ -49,9 +75,22 @@ export async function GET(request: Request): Promise { // A solution served through this host resolves its modules against this // host's own public origin (see surfacesProjection). const hostOrigin = requestPublicOrigin(request); - return Response.json({ - solutions: registered - .map((manifest) => surfacesProjection(manifest, client, hostOrigin)) - .filter((projected) => projected !== null), - }); + // The origin is part of the projection, so it is part of what may be reused: + // two callers reaching this host through different public origins must not + // share a cached answer that resolves a module against one of them. + const solutions = cachedProjection( + entitlements, + `${client}\u0000${hostOrigin ?? ""}`, + registered.revision, + () => + entitledSolutions(registered.solutions, entitlements) + .map(({ manifest, entitlement }) => + surfacesProjection(manifest, client, entitlement, hostOrigin), + ) + .filter( + (projected): projected is SolutionClientSurfaces => + projected !== null, + ), + ); + return Response.json({ solutions }); } diff --git a/module/services/frontend/code/src/features/platform-catalogue/model/facts.ts b/module/services/frontend/code/src/features/platform-catalogue/model/facts.ts index 50c117f94..2ae13b3b8 100644 --- a/module/services/frontend/code/src/features/platform-catalogue/model/facts.ts +++ b/module/services/frontend/code/src/features/platform-catalogue/model/facts.ts @@ -155,13 +155,12 @@ export function registrationView(entry: CatalogueEntry): FactView { tone: "warning", detail: "A half has never registered.", }; - case SolutionRegistrationStatus.EXPIRED: - return { - kind: "value", - text: "Expired", - tone: "warning", - detail: "A half's lease lapsed: its deployment stopped renewing.", - }; + // No EXPIRED case: this host has no lease to lapse. Status is derived + // from declared state and complete, compatible observations, so + // "a half's lease lapsed" is not a thing the registry can report any + // more — the enum dropped the value with the lease itself. A record + // whose halves disagree is INCOMPATIBLE and one whose removal applied + // is TOMBSTONED; neither is an expiry. case SolutionRegistrationStatus.INCOMPATIBLE: return { kind: "value", diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/frontend_catalog.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/frontend_catalog.ts index bc7ccdb0f..1bc58d826 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/frontend_catalog.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/frontend_catalog.ts @@ -26,6 +26,7 @@ import { OrganizationService } from "./organizations_pb"; import { PlatformAdminService } from "./platform_admin_pb"; import { GDPRService } from "./privacy_pb"; import { ResourceFollowService } from "./resource_follows_pb"; +import { SolutionEntitlementService } from "./solution_entitlements_service_pb"; import { SolutionRegistryService } from "./solution_registry_service_pb"; import { SSOAdminService } from "./sso_pb"; import { TeamService } from "./teams_pb"; @@ -196,6 +197,7 @@ export const ACCOUNT_SERVICE_DESCRIPTORS = { PrincipalService, ResourceFollowService, SSOAdminService, + SolutionEntitlementService, SolutionRegistryService, TeamService, UsageService, @@ -236,6 +238,7 @@ export interface AccountsClients { readonly PrincipalService: Client; readonly ResourceFollowService: Client; readonly SSOAdminService: Client; + readonly SolutionEntitlementService: Client; readonly SolutionRegistryService: Client; readonly TeamService: Client; readonly UsageService: Client; @@ -275,6 +278,7 @@ export function createAccountsClients(transport: Transport): AccountsClients { PrincipalService: createClient(PrincipalService, transport), ResourceFollowService: createClient(ResourceFollowService, transport), SSOAdminService: createClient(SSOAdminService, transport), + SolutionEntitlementService: createClient(SolutionEntitlementService, transport), SolutionRegistryService: createClient(SolutionRegistryService, transport), TeamService: createClient(TeamService, transport), UsageService: createClient(UsageService, transport), diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts index dd1b4b934..187cea4f2 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts @@ -15,7 +15,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/installations.proto. */ export const file_saas_accounts_v1_installations: GenFile = /*@__PURE__*/ - fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEilwMKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEhsKE3NvbHV0aW9uX2lkZW50aWZpZXIYBCABKAkSJAoSb3duZXJfcHJpbmNpcGFsX2lkGAUgASgJQgi6SAVyA7ABARIeChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAYgAygJEiQKEnJvb3Rfc2NvcGVfbm9kZV9pZBgHIAEoCUIIukgFcgOwAQESNAoGc3RhdHVzGAggASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdGF0dXMSLgoKY3JlYXRlZF9hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoKcmV2b2tlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBAUINCgtfcmV2b2tlZF9hdCKmAwoWSW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiQKEGFnZW50X2lkZW50aWZpZXIYAiABKAlCCrpIB3IFEAEYgAQSJwoTc29sdXRpb25faWRlbnRpZmllchgDIAEoCUIKukgHcgUQARiABBIeCgxkaXNwbGF5X25hbWUYBCABKAlCCLpIBXIDGIAEEiIKEHJvb3Rfc2NvcGVfbGFiZWwYBiABKAlCCLpIBXIDGIAEEhkKB3JvbGVfaWQYByABKAlCCLpIBXIDsAEBEicKEm93bmVyX3ByaW5jaXBhbF9pZBgIIAEoCUILukgI2AEBcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgJIAMoCUIPukgMkgEJECAiBXIDsAEBEioKEWFsbG93ZWRfYXVkaWVuY2VzGAogAygJQg+6SAySAQkQQCIFcgMYgAQSJwoOYWxsb3dlZF9zY29wZXMYCyADKAlCD7pIDJIBCRBAIgVyAxiAAUoECAUQBlIPcm9vdF9zY29wZV9wYXRoIlcKGFVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEivgEKJFRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESKAoWbmV3X293bmVyX3ByaW5jaXBhbF9pZBgDIAEoCUIIukgFcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgEIAMoCUIPukgMkgEJECAiBXIDsAEBIlUKFkdldEluc3RhbGxhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBIoUBChdHZXRJbnN0YWxsYXRpb25SZXNwb25zZRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCp6ChJJbnN0YWxsYXRpb25TdGF0dXMSIwofSU5TVEFMTEFUSU9OX1NUQVRVU19VTlNQRUNJRklFRBAAEh4KGklOU1RBTExBVElPTl9TVEFUVVNfQUNUSVZFEAESHwobSU5TVEFMTEFUSU9OX1NUQVRVU19SRVZPS0VEEAIqhAIKEkluc3RhbGxhdGlvbkhlYWx0aBIjCh9JTlNUQUxMQVRJT05fSEVBTFRIX1VOU1BFQ0lGSUVEEAASHwobSU5TVEFMTEFUSU9OX0hFQUxUSF9IRUFMVEhZEAESKQolSU5TVEFMTEFUSU9OX0hFQUxUSF9OT19FTElHSUJMRV9PV05FUhACEiUKIUlOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfUkVWT0tFRBADEiYKIklOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfRElTQUJMRUQQBBIuCipJTlNUQUxMQVRJT05fSEVBTFRIX1NUQU5ESU5HX0dSQU5UX01JU1NJTkcQBTLvBgoTSW5zdGFsbGF0aW9uU2VydmljZRK6AQoPSW5zdGFsbFNvbHV0aW9uEiguc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsU29sdXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24iXcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5jcmVhdGVkEAJAAUgEUANYA2ABgtPkkwIWOgEqIhEvdjEvaW5zdGFsbGF0aW9ucxLSAQoRVW5pbnN0YWxsU29sdXRpb24SKi5zYWFzLmFjY291bnRzLnYxLlVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSJ5wvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLnJldm9rZWQQAkABSARQA1gDYAGC0+STAjI6ASoiLS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9OnVuaW5zdGFsbBKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfUK6AQoUY29tLnNhYXMuYWNjb3VudHMudjFCEkluc3RhbGxhdGlvbnNQcm90b1ABWixhY2NvdW50cy9wa2cvZ2VuL3NhYXMvYWNjb3VudHMvdjE7YWNjb3VudHN2MaICA1NBWKoCEFNhYXMuQWNjb3VudHMuVjHKAhBTYWFzXEFjY291bnRzXFYx4gIcU2Fhc1xBY2NvdW50c1xWMVxHUEJNZXRhZGF0YeoCElNhYXM6OkFjY291bnRzOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEi1wMKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEiQKEm93bmVyX3ByaW5jaXBhbF9pZBgFIAEoCUIIukgFcgOwAQESHgoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgGIAMoCRIkChJyb290X3Njb3BlX25vZGVfaWQYByABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgIIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjMKCnJldm9rZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESHgoJdGFyZ2V0X2lkGAsgASgJQgu6SAjYAQFyA7ABARIgCg5yZXZva2VkX3JlYXNvbhgMIAEoCUIIukgFcgMYgARCDQoLX3Jldm9rZWRfYXRKBAgEEAVSE3NvbHV0aW9uX2lkZW50aWZpZXIitQMKFkluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIkChBhZ2VudF9pZGVudGlmaWVyGAIgASgJQgq6SAdyBRABGIAEEh4KDGRpc3BsYXlfbmFtZRgEIAEoCUIIukgFcgMYgAQSIgoQcm9vdF9zY29wZV9sYWJlbBgGIAEoCUIIukgFcgMYgAQSGQoHcm9sZV9pZBgHIAEoCUIIukgFcgOwAQESJwoSb3duZXJfcHJpbmNpcGFsX2lkGAggASgJQgu6SAjYAQFyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAkgAygJQg+6SAySAQkQICIFcgOwAQESKgoRYWxsb3dlZF9hdWRpZW5jZXMYCiADKAlCD7pIDJIBCRBAIgVyAxiABBInCg5hbGxvd2VkX3Njb3BlcxgLIAMoCUIPukgMkgEJEEAiBXIDGIABEhsKCXRhcmdldF9pZBgMIAEoCUIIukgFcgOwAQFKBAgDEARKBAgFEAZSE3NvbHV0aW9uX2lkZW50aWZpZXJSD3Jvb3Rfc2NvcGVfcGF0aCJXChhVbmluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBIr4BCiRUcmFuc2Zlckluc3RhbGxhdGlvbk93bmVyc2hpcFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEigKFm5ld19vd25lcl9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEi8KFmNvX293bmVyX3ByaW5jaXBhbF9pZHMYBCADKAlCD7pIDJIBCRAgIgVyA7ABASJVChZHZXRJbnN0YWxsYXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASKFAQoXR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2USNAoMaW5zdGFsbGF0aW9uGAEgASgLMh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24SNAoGaGVhbHRoGAIgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25IZWFsdGginQEKGExpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEh0KCXBhZ2Vfc2l6ZRgDIAEoBUIKukgHGgUY9AMoABISCgpwYWdlX3Rva2VuGAQgASgJIoEBChNJbnN0YWxsYXRpb25TdW1tYXJ5EjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoInIKGUxpc3RJbnN0YWxsYXRpb25zUmVzcG9uc2USPAoNaW5zdGFsbGF0aW9ucxgBIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3VtbWFyeRIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAki7QEKEUF2YWlsYWJsZVNvbHV0aW9uEhsKCXRhcmdldF9pZBgBIAEoCUIIukgFcgOwAQESEgoKYmluZGluZ19pZBgCIAEoCRITCgtyb3V0ZV9hbGlhcxgDIAEoCRIZChFyZWxlYXNlX3B1Ymxpc2hlchgEIAEoCRIUCgxyZWxlYXNlX25hbWUYBSABKAkSFwoPcmVsZWFzZV92ZXJzaW9uGAYgASgJEhkKEW9wZW5lZF9nZW5lcmF0aW9uGAcgASgEEhoKEmFwcGxpZWRfZ2VuZXJhdGlvbhgIIAEoBBIRCglpbnN0YWxsZWQYCSABKAgibAodTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSJxCh5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVzcG9uc2USNgoJc29sdXRpb25zGAEgAygLMiMuc2Fhcy5hY2NvdW50cy52MS5BdmFpbGFibGVTb2x1dGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkqegoSSW5zdGFsbGF0aW9uU3RhdHVzEiMKH0lOU1RBTExBVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABIeChpJTlNUQUxMQVRJT05fU1RBVFVTX0FDVElWRRABEh8KG0lOU1RBTExBVElPTl9TVEFUVVNfUkVWT0tFRBACKoQCChJJbnN0YWxsYXRpb25IZWFsdGgSIwofSU5TVEFMTEFUSU9OX0hFQUxUSF9VTlNQRUNJRklFRBAAEh8KG0lOU1RBTExBVElPTl9IRUFMVEhfSEVBTFRIWRABEikKJUlOU1RBTExBVElPTl9IRUFMVEhfTk9fRUxJR0lCTEVfT1dORVIQAhIlCiFJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX1JFVk9LRUQQAxImCiJJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX0RJU0FCTEVEEAQSLgoqSU5TVEFMTEFUSU9OX0hFQUxUSF9TVEFORElOR19HUkFOVF9NSVNTSU5HEAUyxQkKE0luc3RhbGxhdGlvblNlcnZpY2USugEKD0luc3RhbGxTb2x1dGlvbhIoLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbFNvbHV0aW9uUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIl3C8xg9CAIQBCoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy5pbnN0YWxsYXRpb24uY3JlYXRlZBACQAFIBFADWANgAYLT5JMCFjoBKiIRL3YxL2luc3RhbGxhdGlvbnMS0gEKEVVuaW5zdGFsbFNvbHV0aW9uEiouc2Fhcy5hY2NvdW50cy52MS5Vbmluc3RhbGxTb2x1dGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiecLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5yZXZva2VkEAJAAUgEUANYA2ABgtPkkwIyOgEqIi0vdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfTp1bmluc3RhbGwSiQIKHVRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwEjYuc2Fhcy5hY2NvdW50cy52MS5UcmFuc2Zlckluc3RhbGxhdGlvbk93bmVyc2hpcFJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiKPAcLzGEsIAhAEKgwKBm9yZ19pZBACGAEwATorCidzYWFzLmluc3RhbGxhdGlvbi5vd25lcnNoaXBfdHJhbnNmZXJyZWQQAkABSARQA1gDYAGC0+STAjo6ASoiNS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9OnRyYW5zZmVyT3duZXJzaGlwErkBCg9HZXRJbnN0YWxsYXRpb24SKC5zYWFzLmFjY291bnRzLnYxLkdldEluc3RhbGxhdGlvblJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLkdldEluc3RhbGxhdGlvblJlc3BvbnNlIlHC8xgiCAIQAyoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCJRIjL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH0ShgEKEUxpc3RJbnN0YWxsYXRpb25zEiouc2Fhcy5hY2NvdW50cy52MS5MaXN0SW5zdGFsbGF0aW9uc1JlcXVlc3QaKy5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLKAQoWTGlzdEF2YWlsYWJsZVNvbHV0aW9ucxIvLnNhYXMuYWNjb3VudHMudjEuTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkxpc3RBdmFpbGFibGVTb2x1dGlvbnNSZXNwb25zZSJNwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQA1gDYAGC0+STAiESHy92MS9pbnN0YWxsYXRpb25zOmxpc3RBdmFpbGFibGVCugEKFGNvbS5zYWFzLmFjY291bnRzLnYxQhJJbnN0YWxsYXRpb25zUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); /** * Installation is one solution's bound identity in an org. Server-assigned ids; @@ -39,11 +39,6 @@ export type Installation = Message<"saas.accounts.v1.Installation"> & { */ agentPrincipalId: string; - /** - * @generated from field: string solution_identifier = 4; - */ - solutionIdentifier: string; - /** * @generated from field: string owner_principal_id = 5; */ @@ -73,6 +68,36 @@ export type Installation = Message<"saas.accounts.v1.Installation"> & { * @generated from field: optional google.protobuf.Timestamp revoked_at = 10; */ revokedAt?: Timestamp; + + /** + * The immutable solution target this installation was consented to: one + * continuous period of one binding's presence on this host. It is never + * reused, so a replacement binding — even one claiming the same route alias — + * is a different target and inherits nothing. + * + * Deliberately NOT accompanied by the route alias. The alias is a property of + * the target that a later generation may move, and resolving it here would + * mean joining the host's presence state into a request-path listing — which + * would require granting request traffic read over every organisation's + * presence to answer about one. A consumer that needs the alias asks + * ListAvailableSolutions, which serves accepted applied state, and joins on + * this id. + * + * Empty only on a revoked historical row whose target could not be + * established at the cutover; an active installation always names one. + * + * @generated from field: string target_id = 11; + */ + targetId: string; + + /** + * Why a revoked installation was revoked, when it was not an administrator + * uninstalling: the installation-identity cutover, or a tenancy narrowing that + * withdrew this organisation. Empty on an active row. + * + * @generated from field: string revoked_reason = 12; + */ + revokedReason: string; }; /** @@ -99,11 +124,6 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe */ agentIdentifier: string; - /** - * @generated from field: string solution_identifier = 3; - */ - solutionIdentifier: string; - /** * Human display name for the agent principal. Empty defaults to the identifier. * @@ -157,6 +177,19 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe * @generated from field: repeated string allowed_scopes = 11; */ allowedScopes: string[]; + + /** + * The solution target being installed, from ListAvailableSolutions. Required. + * + * An installer chooses an identity, never an address. The target must be LIVE + * and ACCEPTED when the install lands: installing a withdrawn target would + * record consent to a presence that already ended, and installing a target + * whose newest generation was refused would record consent to a release this + * host never admitted. + * + * @generated from field: string target_id = 12; + */ + targetId: string; }; /** @@ -270,6 +303,235 @@ export type GetInstallationResponse = Message<"saas.accounts.v1.GetInstallationR export const GetInstallationResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_installations, 5); +/** + * ListInstallationsRequest asks what one organization has installed. Every other + * RPC on this service names a single installation, so until this existed nothing + * could answer "what has this org installed?" — which is the authority read a + * per-organization solution projection is built on (issue #949). + * + * @generated from message saas.accounts.v1.ListInstallationsRequest + */ +export type ListInstallationsRequest = Message<"saas.accounts.v1.ListInstallationsRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * Restrict to one lifecycle status. UNSPECIFIED returns every status, so a + * caller that must see only live installs asks for ACTIVE explicitly rather + * than leaning on a default that would silently widen if the enum grew. + * + * @generated from field: saas.accounts.v1.InstallationStatus status = 2; + */ + status: InstallationStatus; + + /** + * Page bound. 0 -> server default; capped so an organization with many + * installations can never return an unbounded result in one call. + * + * @generated from field: int32 page_size = 3; + */ + pageSize: number; + + /** + * Opaque cursor from a previous response's next_page_token; empty for page one. + * + * @generated from field: string page_token = 4; + */ + pageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListInstallationsRequest. + * Use `create(ListInstallationsRequestSchema)` to create a new message. + */ +export const ListInstallationsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 6); + +/** + * InstallationSummary is one row of the listing: the installation, plus the health + * resolved live beside it exactly as GetInstallation resolves it for one. Health + * travels with the row because a consumer that renders an installed solution as + * unavailable would otherwise need one GetInstallation per installation. + * + * @generated from message saas.accounts.v1.InstallationSummary + */ +export type InstallationSummary = Message<"saas.accounts.v1.InstallationSummary"> & { + /** + * @generated from field: saas.accounts.v1.Installation installation = 1; + */ + installation?: Installation; + + /** + * @generated from field: saas.accounts.v1.InstallationHealth health = 2; + */ + health: InstallationHealth; +}; + +/** + * Describes the message saas.accounts.v1.InstallationSummary. + * Use `create(InstallationSummarySchema)` to create a new message. + */ +export const InstallationSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 7); + +/** + * @generated from message saas.accounts.v1.ListInstallationsResponse + */ +export type ListInstallationsResponse = Message<"saas.accounts.v1.ListInstallationsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.InstallationSummary installations = 1; + */ + installations: InstallationSummary[]; + + /** + * Set when more installations remain; pass it back as page_token. Empty on the + * last page. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListInstallationsResponse. + * Use `create(ListInstallationsResponseSchema)` to create a new message. + */ +export const ListInstallationsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 8); + +/** + * AvailableSolution is one installable solution target: its immutable identity, + * the release the host currently has applied for it, and where it is routed. + * + * @generated from message saas.accounts.v1.AvailableSolution + */ +export type AvailableSolution = Message<"saas.accounts.v1.AvailableSolution"> & { + /** + * The immutable target identity an install names. Never reused. + * + * @generated from field: string target_id = 1; + */ + targetId: string; + + /** + * The binding whose presence this target records. Carried so an operator can + * correlate a catalogue row with delivery; an installation does not join on it, + * because one binding may have several targets over time. + * + * @generated from field: string binding_id = 2; + */ + bindingId: string; + + /** + * The route alias the applied generation claims — `/solutions//*`. A + * later generation may move it, so it is never an identity. + * + * @generated from field: string route_alias = 3; + */ + routeAlias: string; + + /** + * The applied release: publisher, name, version. + * + * @generated from field: string release_publisher = 4; + */ + releasePublisher: string; + + /** + * @generated from field: string release_name = 5; + */ + releaseName: string; + + /** + * @generated from field: string release_version = 6; + */ + releaseVersion: string; + + /** + * The generation that opened this target, and the newest applied generation. + * Equal until a later generation is applied for the same presence. + * + * @generated from field: uint64 opened_generation = 7; + */ + openedGeneration: bigint; + + /** + * @generated from field: uint64 applied_generation = 8; + */ + appliedGeneration: bigint; + + /** + * True when this organization already has an ACTIVE installation of this + * target. A catalogue shows it as installed rather than offering it twice; the + * unique index over (org_id, target_id) would refuse the second install anyway, + * and an administrator should not have to discover that by being refused. + * + * @generated from field: bool installed = 9; + */ + installed: boolean; +}; + +/** + * Describes the message saas.accounts.v1.AvailableSolution. + * Use `create(AvailableSolutionSchema)` to create a new message. + */ +export const AvailableSolutionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 9); + +/** + * @generated from message saas.accounts.v1.ListAvailableSolutionsRequest + */ +export type ListAvailableSolutionsRequest = Message<"saas.accounts.v1.ListAvailableSolutionsRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * Page bound. 0 -> server default; capped for the same reason the installation + * listing is capped. + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListAvailableSolutionsRequest. + * Use `create(ListAvailableSolutionsRequestSchema)` to create a new message. + */ +export const ListAvailableSolutionsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 10); + +/** + * @generated from message saas.accounts.v1.ListAvailableSolutionsResponse + */ +export type ListAvailableSolutionsResponse = Message<"saas.accounts.v1.ListAvailableSolutionsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.AvailableSolution solutions = 1; + */ + solutions: AvailableSolution[]; + + /** + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListAvailableSolutionsResponse. + * Use `create(ListAvailableSolutionsResponseSchema)` to create a new message. + */ +export const ListAvailableSolutionsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_installations, 11); + /** * InstallationStatus is the lifecycle of an installation row. An uninstall is a * soft delete (status=revoked, revoked_at set) so the agent, grants, and audit @@ -407,6 +669,46 @@ export const InstallationService: GenService<{ input: typeof GetInstallationRequestSchema; output: typeof GetInstallationResponseSchema; }, + /** + * ListInstallations enumerates one organization's installations with their live + * health. It is internal-tier on purpose: it takes the organization as a request + * field, so only a caller holding the cluster-internal credential may ask, and + * the tenant it asks about is the one the auth-gateway projected from a verified + * identity — never one a browser supplied. It sits beside + * PermissionService.ListAccessibleScopes for that reason: the two are read + * together to answer what a viewer's organization installed and what that viewer + * was granted. + * + * @generated from rpc saas.accounts.v1.InstallationService.ListInstallations + */ + listInstallations: { + methodKind: "unary"; + input: typeof ListInstallationsRequestSchema; + output: typeof ListInstallationsResponseSchema; + }, + /** + * ListAvailableSolutions is the catalogue: the solution targets an + * administrator may install right now. + * + * It answers from ACCEPTED applied state — a live target whose binding's + * newest APPLIED generation is a present one — and deliberately not from the + * diagnostic ListSolutionHostBindings, which also reports desired generations + * that were refused. The distinction is the point: a refused generation is + * something an operator must see and something an administrator must not be + * able to consent to, because consenting to a release this host never admitted + * records authority over a presence that does not exist. + * + * It carries the route alias, which the installation record deliberately does + * not: here the alias is a display and routing fact read from the target in + * the same statement, not an identity anything joins on. + * + * @generated from rpc saas.accounts.v1.InstallationService.ListAvailableSolutions + */ + listAvailableSolutions: { + methodKind: "unary"; + input: typeof ListAvailableSolutionsRequestSchema; + output: typeof ListAvailableSolutionsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_saas_accounts_v1_installations, 0); diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/module_capabilities_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/module_capabilities_pb.ts index 1286fe80b..afe588cc2 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/module_capabilities_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/module_capabilities_pb.ts @@ -22,7 +22,7 @@ import type { JsonObject, Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/module_capabilities.proto. */ export const file_saas_accounts_v1_module_capabilities: GenFile = /*@__PURE__*/ - fileDesc("CipzYWFzL2FjY291bnRzL3YxL21vZHVsZV9jYXBhYmlsaXRpZXMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoXTW9kdWxlRW5xdWV1ZUpvYlJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIpCgNqb2IYAiABKAsyFC5zYWFzLmpvYnMudjEuTmV3Sm9iQga6SAPIAQEidgoYTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlEhgKBmpvYl9pZBgBIAEoCUIIukgFcgOwAQESQAoLZGlzcG9zaXRpb24YAiABKA4yIy5zYWFzLmpvYnMudjEuSm9iRW5xdWV1ZURpc3Bvc2l0aW9uQga6SAPIAQEiygEKFk1vZHVsZUNsYWltSm9ic1JlcXVlc3QSLgoFcXVldWUYASABKAlCH7pIHHIaEAEYgAEyE15bYS16XVthLXowLTlfLi1dKiQSHQoJd29ya2VyX2lkGAIgASgJQgq6SAdyBRABGP8BEhgKBWxpbWl0GAMgASgNQgm6SAYqBBhkKAESRwoObGVhc2VfZHVyYXRpb24YBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IkIKF01vZHVsZUNsYWltSm9ic1Jlc3BvbnNlEicKBGpvYnMYASADKAsyGS5zYWFzLmpvYnMudjEuSm9iRW52ZWxvcGUilwEKGU1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARJCCglleHRlbnNpb24YAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IksKGk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlEi0KBWxlYXNlGAEgASgLMhYuc2Fhcy5qb2JzLnYxLkpvYkxlYXNlQga6SAPIAQEipgEKE01vZHVsZUFja0pvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARI3Cg5leGVjdXRpb25fa2luZBgCIAEoCUIfukgcchoYQDIWXiR8XlthLXpdW2EtejAtOV8uLV0qJBIeCgxleGVjdXRpb25faWQYAyABKAlCCLpIBXIDGP8BIsIBChRNb2R1bGVOYWNrSm9iUmVxdWVzdBI2CgVsZWFzZRgBIAEoCzIfLnNhYXMuam9icy52MS5Kb2JMZWFzZVJlZmVyZW5jZUIGukgDyAEBEjEKB2ZhaWx1cmUYAiABKAsyGC5zYWFzLmpvYnMudjEuSm9iRmFpbHVyZUIGukgDyAEBEhEKCXJldHJ5YWJsZRgDIAEoCBIsCghyZXRyeV9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi9gEKF01vZHVsZU5vdGlmeVVzZXJSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESGQoHdXNlcl9pZBgCIAEoCUIIukgFcgOwAQESGQoFdGl0bGUYAyABKAlCCrpIB3IFEAEY5gcSFgoEYm9keRgEIAEoCUIIukgFcgMYgEASFQoEdHlwZRgFIAEoCUIHukgEcgIYQBIcCgphY3Rpb25fdXJsGAYgASgJQgi6SAVyAxiAEBIbCghjYXRlZ29yeRgHIAEoCUIJukgGcgQQARggEiEKD2lkZW1wb3RlbmN5X2tleRgIIAEoCUIIukgFcgMY/wEiRgoYTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlEhcKD25vdGlmaWNhdGlvbl9pZBgBIAEoCRIRCglkZWxpdmVyZWQYAiABKAgi4AEKHE1vZHVsZU5vdGlmeU9yZ0FkbWluc1JlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIZCgV0aXRsZRgCIAEoCUIKukgHcgUQARjmBxIWCgRib2R5GAMgASgJQgi6SAVyAxiAQBIVCgR0eXBlGAQgASgJQge6SARyAhhAEhwKCmFjdGlvbl91cmwYBSABKAlCCLpIBXIDGIAQEhsKCGNhdGVnb3J5GAYgASgJQgm6SAZyBBABGCASIQoPaWRlbXBvdGVuY3lfa2V5GAcgASgJQgi6SAVyAxj/ASIyCh1Nb2R1bGVOb3RpZnlPcmdBZG1pbnNSZXNwb25zZRIRCglkZWxpdmVyZWQYASABKAgiYwoUTW9kdWxlQXBwcm92YWxQb2xpY3kSFwoGcXVvcnVtGAEgASgNQge6SAQqAhhkEh4KDGFwcHJvdmVyX3NldBgCIAMoCUIIukgFkgECEGQSEgoKYWxsb3dfc2VsZhgDIAEoCCKbAQoPTW9kdWxlUmVzdW1lUmVmEi4KBXF1ZXVlGAEgASgJQh+6SBxyGhABGIABMhNeW2Etel1bYS16MC05Xy4tXSokEi4KBXRvcGljGAIgASgJQh+6SBxyGhABGP8BMhNeW2Etel1bYS16MC05Xy4tXSokEigKB3BheWxvYWQYAyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0IpYDChxNb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHAoIcmVzb3VyY2UYAiABKAlCCrpIB3IFEAEYgAESGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIABEigKB3N1YmplY3QYBCABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiAKDHJlcXVlc3RlZF9ieRgFIAEoCUIKukgHcgUQARj/ARI2CgZwb2xpY3kYBiABKAsyJi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsUG9saWN5Ej0KCnJlc3VtZV9yZWYYByABKAsyIS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJlc3VtZVJlZkIGukgDyAEBEi4KCmV4cGlyZXNfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCI+Ch1Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZRIdCgthcHByb3ZhbF9pZBgBIAEoCUIIukgFcgOwAQEiUwoYTW9kdWxlR2V0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHQoLYXBwcm92YWxfaWQYAiABKAlCCLpIBXIDsAEBIvUCCg5Nb2R1bGVBcHByb3ZhbBIKCgJpZBgBIAEoCRIOCgZ0ZW5hbnQYAiABKAkSEAoIcmVzb3VyY2UYAyABKAkSDgoGYWN0aW9uGAQgASgJEigKB3N1YmplY3QYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHJlcXVlc3RlZF9ieRgGIAEoCRIOCgZxdW9ydW0YByABKA0SDQoFc3RhdGUYCCABKAkSNQoKcmVzdW1lX3JlZhgJIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlUmVzdW1lUmVmEi4KCmV4cGlyZXNfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAwgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJwChtNb2R1bGVDYW5jZWxBcHByb3ZhbFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIdCgthcHByb3ZhbF9pZBgCIAEoCUIIukgFcgOwAQESGAoGcmVhc29uGAMgASgJQgi6SAVyAxiACCLuAQobTW9kdWxlRW1pdEF1ZGl0RXZlbnRSZXF1ZXN0Eg4KBnRlbmFudBgBIAEoCRIeCgpldmVudF90eXBlGAIgASgJQgq6SAdyBRABGIABEhkKBWFjdG9yGAMgASgJQgq6SAdyBRABGP8BEhwKCHNvbHV0aW9uGAQgASgJQgq6SAdyBRABGIABEhoKCGVudHJ5X2lkGAUgASgJQgi6SAVyAxj/ARInCgZmaWVsZHMYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiEKD2lkZW1wb3RlbmN5X2tleRgHIAEoCUIIukgFcgMY/wEinwEKG01vZHVsZUF1ZGl0RmllbGREZWNsYXJhdGlvbhIXCgRuYW1lGAEgASgJQgm6SAZyBBABGEASQAoEa2luZBgCIAEoDjImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlQXVkaXRGaWVsZEtpbmRCCrpIB4IBBBABIAASGAoGdmFsdWVzGAMgAygJQgi6SAWSAQIQQBILCgNwaWkYBCABKAgi7wEKH01vZHVsZUF1ZGl0RXZlbnRUeXBlRGVjbGFyYXRpb24SGAoEdHlwZRgBIAEoCUIKukgHcgUQARiAARIdCgtkZXNjcmlwdGlvbhgCIAEoCUIIukgFcgMYgAgSRwoGZmllbGRzGAMgAygLMi0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEZpZWxkRGVjbGFyYXRpb25CCLpIBZIBAhAgEkoKCnZpc2liaWxpdHkYBCABKA4yLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUF1ZGl0RXZlbnRWaXNpYmlsaXR5Qgi6SAWCAQIQASKzAQojTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QSPgoGcHJlZml4GAEgASgJQi66SCtyKRABGIABMiJeW2EtejAtOV0oPzpbYS16MC05Xy1dKlthLXowLTldKT8kEkwKBXR5cGVzGAIgAygLMjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEV2ZW50VHlwZURlY2xhcmF0aW9uQgq6SAeSAQQIARBAIl0KJE1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZRIWCg5hZG1pdHRlZF90eXBlcxgBIAMoCRIdChVuYW1lc3BhY2VzX3Rha2VuX292ZXIYAiADKAkibQoWU3ViamVjdFZpc2liaWxpdHlHcmFudBIjChJ2aXNpYmxlX3N1YmplY3RfaWQYASABKAlCB7pIBHICEAESLgoKZXhwaXJlc19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiYwoiTW9kdWxlTGlzdFN1YmplY3RWaXNpYmlsaXR5UmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEiMKEXZpZXdlcl9zdWJqZWN0X2lkGAIgASgJQgi6SAVyA7ABASJfCiNNb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZRI4CgZncmFudHMYASADKAsyKC5zYWFzLmFjY291bnRzLnYxLlN1YmplY3RWaXNpYmlsaXR5R3JhbnQiVwoaRmV0Y2hEYXRhc291cmNlQmxvYlJlcXVlc3QSGwoJc291cmNlX2lkGAEgASgJQgi6SAVyA7ABARIcCghibG9iX3NoYRgCIAEoCUIKukgHcgUQARj/ASJSChhGZXRjaERhdGFzb3VyY2VCbG9iQ2h1bmsSDAoEZGF0YRgBIAEoDBISCgp0b3RhbF9zaXplGAIgASgDEhQKDGNvbnRlbnRfdHlwZRgDIAEoCSJSChFEYXRhc291cmNlRmlsZVJlZhIbCgdpdGVtX2lkGAEgASgJQgq6SAdyBRABGIAgEiAKDGl0ZW1fdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ASKYAQobRmV0Y2hEYXRhc291cmNlRmlsZXNSZXF1ZXN0EhsKCXNvdXJjZV9pZBgBIAEoCUIIukgFcgOwAQESGwoHdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ARI/CgVmaWxlcxgDIAMoCzIjLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUZpbGVSZWZCC7pICJIBBQgBEOgHIosBChREYXRhc291cmNlUHJvdmVuYW5jZRIRCglzb3VyY2VfaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEhgKEGJvdW5kYXJ5X25vZGVfaWQYAyABKAkSDwoHdmVyc2lvbhgEIAEoCRIPCgdpdGVtX2lkGAUgASgJEhQKDGl0ZW1fdmVyc2lvbhgGIAEoCSLAAQoVRGF0YXNvdXJjZUl0ZW1SZWFkZXJzEjsKBWJhc2lzGAEgASgOMiwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnNCYXNpcxIQCgh1c2VyX2lkcxgCIAMoCRIRCglncm91cF9pZHMYAyADKAkSGAoQYm91bmRhcnlfcmVhZGVycxgEIAEoCBIWCg51bm1hcHBlZF9jb3VudBgFIAEoBRITCgthY2xfdmVyc2lvbhgGIAEoCSK+AQoURGF0YXNvdXJjZUZpbGVIZWFkZXISOgoKcHJvdmVuYW5jZRgBIAEoCzImLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZlbmFuY2USDAoEcGF0aBgCIAEoCRIUCgxjb250ZW50X3R5cGUYAyABKAkSDAoEc2l6ZRgEIAEoAxI4CgdyZWFkZXJzGAUgASgLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnMibgoZRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZRI4CgZoZWFkZXIYASABKAsyJi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VGaWxlSGVhZGVySAASDgoEZGF0YRgCIAEoDEgAQgcKBWZyYW1lIm4KGU1vZHVsZVB1Ymxpc2hFdmVudFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARI3CghlbnZlbG9wZRgCIAEoCzIdLnNhYXMuZXZlbnRzLnYxLkV2ZW50RW52ZWxvcGVCBrpIA8gBASI4ChpNb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZRIaCghldmVudF9pZBgBIAEoCUIIukgFcgOwAQEiyAEKFk1vZHVsZVN1YnNjcmliZVJlcXVlc3QSSwoMdHlwZV9wYXR0ZXJuGAEgASgJQjW6SDJyMBABGIABMileW2Etel1bYS16MC05XSooPzpcLlthLXowLTldKykqKD86XC5cKik/JBIuCgVxdWV1ZRgCIAEoCUIfukgcchoQARiAATITXlthLXpdW2EtejAtOV8uLV0qJBIxCghkZWxpdmVyeRgDIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeSLJAQoSTW9kdWxlU3Vic2NyaXB0aW9uEgoKAmlkGAEgASgJEh8KF3N1YnNjcmliZXJfcHJpbmNpcGFsX2lkGAIgASgJEhQKDHR5cGVfcGF0dGVybhgDIAEoCRINCgVxdWV1ZRgEIAEoCRIxCghkZWxpdmVyeRgFIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJVChdNb2R1bGVTdWJzY3JpYmVSZXNwb25zZRI6CgxzdWJzY3JpcHRpb24YASABKAsyJC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVN1YnNjcmlwdGlvbiI9ChhNb2R1bGVVbnN1YnNjcmliZVJlcXVlc3QSIQoPc3Vic2NyaXB0aW9uX2lkGAEgASgJQgi6SAVyA7ABASIgCh5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QiXgofTW9kdWxlTGlzdFN1YnNjcmlwdGlvbnNSZXNwb25zZRI7Cg1zdWJzY3JpcHRpb25zGAEgAygLMiQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVTdWJzY3JpcHRpb24iegoZTW9kdWxlUmVwbGF5RXZlbnRzUmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEhgKBHR5cGUYAiABKAlCCrpIB3IFEAEYgAESKQoFc2luY2UYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIjEKGk1vZHVsZVJlcGxheUV2ZW50c1Jlc3BvbnNlEhMKC3JlZGVsaXZlcmVkGAEgASgFItMCChhSZWFkYWJsZVNvdXJjZUNvbGxlY3Rpb24SEQoJc291cmNlX2lkGAEgASgJEhMKC2JvdW5kYXJ5X2lkGAIgASgJEg4KBm9yaWdpbhgDIAEoCRIRCgljb250YWluZXIYBCABKAkSCwoDcmVmGAUgASgJEg0KBXBhdGhzGAYgAygJEhYKDmJvdW5kYXJ5X2xhYmVsGAcgASgJEj4KEGdyYW50X2Rpc2Nsb3N1cmUYCCABKA4yJC5zYWFzLmFjY291bnRzLnYxLk1ldGFkYXRhRGlzY2xvc3VyZRI+CgtyZWFkX2dyYW50cxgJIAMoCzIpLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVDb2xsZWN0aW9uR3JhbnQSOAoEc3luYxgKIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuQ29sbGVjdGlvblN5bmNQcm92ZW5hbmNlIrABChdSZWFkYWJsZUNvbGxlY3Rpb25HcmFudBIVCg1zdWJqZWN0X2xhYmVsGAEgASgJEhQKDHN1YmplY3Rfa2luZBgCIAEoCRIRCglyb2xlX25hbWUYAyABKAkSEgoKc2NvcGVfcGF0aBgEIAEoCRIRCglpbmhlcml0ZWQYBSABKAgSLgoKZXhwaXJlc19hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiqQIKGENvbGxlY3Rpb25TeW5jUHJvdmVuYW5jZRIwCgVzdGFnZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1N0YWdlEiYKAmF0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIQCghyZXZpc2lvbhgDIAEoCRIPCgd0cmlnZ2VyGAQgASgJEkIKFHJlcXVlc3Rlcl9kaXNjbG9zdXJlGAUgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5NZXRhZGF0YURpc2Nsb3N1cmUSMAoMcmVxdWVzdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIaChJyZXF1ZXN0ZWRfYnlfbGFiZWwYByABKAkiYwokTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Eh0KCXBhZ2Vfc2l6ZRgBIAEoBUIKukgHGgUY6AcoABIcCgpwYWdlX3Rva2VuGAIgASgJQgi6SAVyAxiAECKBAQolTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXNwb25zZRI/Cgtjb2xsZWN0aW9ucxgBIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSLpAQoYTW9kdWxlUGxhY2VSZWNvcmRSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESPQoKc2NvcGVfcGF0aBgCIAEoCUIpukgmciQQARiABDIdXlthLXowLTlfXSsoPzpcLlthLXowLTlfXSspKiQSFwoEa2luZBgDIAEoCUIJukgGcgQQARhAEhcKBWxhYmVsGAQgASgJQgi6SAVyAxj/ARIhCg1yZXNvdXJjZV90eXBlGAUgASgJQgq6SAdyBRABGIABEh8KC3Jlc291cmNlX2lkGAYgASgJQgq6SAdyBRABGIAEIjYKGU1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2USGQoHbm9kZV9pZBgBIAEoCUIIukgFcgOwAQEihQEKI0NoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXF1ZXN0EiEKDXJlc291cmNlX3R5cGUYASABKAlCCrpIB3IFEAEYgAISHwoLcmVzb3VyY2VfaWQYAiABKAlCCrpIB3IFEAEYgAQSGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIACIk4KJENoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZRIPCgdhbGxvd2VkGAEgASgIEhUKDXNjb3BlX25vZGVfaWQYAiABKAkifAoqTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRSZWFkQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAIigAMKL01vZHVsZUV4Y2hhbmdlRGVsZWdhdGVkT3BlcmF0aW9uQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLAoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUIJukgGcgQYgIACEg4KBmxvb2t1cBgDIAEoCBIiCg1kZWxlZ2F0aW9uX2lkGAQgASgJQgu6SAjYAQFyA7ABATrKAbpIxgEawwEKM2V4Y2hhbmdlX2RlbGVnYXRlZF9vcGVyYXRpb25fYXVkaWVuY2Uub25lX2F1dGhvcml0eRJGZXhhY3RseSBvbmUgb2YgcGFyZW50X3dvcmtfY29udGV4dF90b2tlbiBhbmQgZGVsZWdhdGlvbl9pZCBpcyByZXF1aXJlZBpEKHRoaXMucGFyZW50X3dvcmtfY29udGV4dF90b2tlbiAhPSAnJykgIT0gKHRoaXMuZGVsZWdhdGlvbl9pZCAhPSAnJykifQoaRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3QSFwoEa2luZBgBIAEoCUIJukgGcgQQARhAEhgKBG5hbWUYAiABKAlCCrpIB3IFEAEYgAQSLAoGZGlnZXN0GAMgASgJQhy6SBlyFzIVXnNoYTI1NjpbMC05YS1mXXs2NH0kIuIBChpFeGVjdXRhYmxlQXJ0aWZhY3RJZGVudGl0eRIaCgZzY2hlbWEYASABKAlCCrpIB3IFEAEYgAESGgoGc291cmNlGAIgASgJQgq6SAdyBRABGIAEEhwKB3N1YmplY3QYAyABKAxCC7pICHoGEAEYgIAEEkoKCWNvbnRyYWN0cxgEIAMoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3RCCbpIBpIBAxCAAhIiChFleHBlY3RlZF9yZXZpc2lvbhgFIAEoA0IHukgEIgIoACLbAQofTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KCXBvbGljeV9pZBgDIAEoCUIKukgHcgUQARiAARJGCghpZGVudGl0eRgEIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0SWRlbnRpdHlCBrpIA8gBASKZAQolTW9kdWxlUmV2b2tlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KC2FwcHJvdmFsX2lkGAMgASgJQgi6SAVyA7ABASJ9CiBNb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZRIWCg5zY2hlbWFfdmVyc2lvbhgBIAEoCRIWCg5xdWFsaWZpZWRfbmFtZRgCIAEoCRIOCgZkaWdlc3QYAyABKAkSGQoRZXhwZWN0ZWRfcmV2aXNpb24YBCABKAMidQogTW9kdWxlQ3VycmVudEluc3RhbGxhdGlvblJlcXVlc3QSLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgBIAEoCUILukgIcgYQARiAgAISIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASJsCiFNb2R1bGVDdXJyZW50SW5zdGFsbGF0aW9uUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEhEKCXRlbmFudF9pZBgCIAEoCRIbChNzb2x1dGlvbl9pZGVudGlmaWVyGAMgASgJKrgCChRNb2R1bGVBdWRpdEZpZWxkS2luZBInCiNNT0RVTEVfQVVESVRfRklFTERfS0lORF9VTlNQRUNJRklFRBAAEiIKHk1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklORxABEiAKHE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1VVSUQQAhIfChtNT0RVTEVfQVVESVRfRklFTERfS0lORF9JTlQQAxIiCh5NT0RVTEVfQVVESVRfRklFTERfS0lORF9OVU1CRVIQBBIgChxNT0RVTEVfQVVESVRfRklFTERfS0lORF9CT09MEAUSIAocTU9EVUxFX0FVRElUX0ZJRUxEX0tJTkRfRU5VTRAGEigKJE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklOR19BUlJBWRAHKqEBChpNb2R1bGVBdWRpdEV2ZW50VmlzaWJpbGl0eRItCilNT0RVTEVfQVVESVRfRVZFTlRfVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEigKJE1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX1RFTkFOVBABEioKJk1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX0VYVEVSTkFMEAIq3AEKGkRhdGFzb3VyY2VJdGVtUmVhZGVyc0Jhc2lzEi0KKURBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1VOU1BFQ0lGSUVEEAASLAooREFUQVNPVVJDRV9JVEVNX1JFQURFUlNfQkFTSVNfVFJBTlNMQVRFRBABEi8KK0RBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1NPVVJDRV9TQ09QRUQQAhIwCixEQVRBU09VUkNFX0lURU1fUkVBREVSU19CQVNJU19VTlRSQU5TTEFUQUJMRRADKmkKDUV2ZW50RGVsaXZlcnkSHgoaRVZFTlRfREVMSVZFUllfVU5TUEVDSUZJRUQQABIcChhFVkVOVF9ERUxJVkVSWV9VTk9SREVSRUQQARIaChZFVkVOVF9ERUxJVkVSWV9PUkRFUkVEEAIqfgoSTWV0YWRhdGFEaXNjbG9zdXJlEiMKH01FVEFEQVRBX0RJU0NMT1NVUkVfVU5TUEVDSUZJRUQQABIhCh1NRVRBREFUQV9ESVNDTE9TVVJFX0RJU0NMT1NFRBABEiAKHE1FVEFEQVRBX0RJU0NMT1NVUkVfV0lUSEhFTEQQAipcCg9Tb3VyY2VTeW5jU3RhZ2USIQodU09VUkNFX1NZTkNfU1RBR0VfVU5TUEVDSUZJRUQQABImCiJTT1VSQ0VfU1lOQ19TVEFHRV9DSEFOR0VTX0VOUVVFVUVEEAEypykKGU1vZHVsZUNhcGFiaWxpdGllc1NlcnZpY2USmwEKFkdldEN1cnJlbnRJbnN0YWxsYXRpb24SMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDdXJyZW50SW5zdGFsbGF0aW9uUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1AEWANgARLGAQoZQXBwcm92ZUV4ZWN1dGFibGVBcnRpZmFjdBIxLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBoyLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVzcG9uc2UiQsLzGD4IAxABMAE6LAooc2Fhcy5tb2R1bGUuZXhlY3V0YWJsZV9hcnRpZmFjdF9kZWNpc2lvbhACQAFIB1AEWAJgARKeAQobQXV0aG9yaXplRXhlY3V0YWJsZUFydGlmYWN0EjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUARYAmABEssBChhSZXZva2VFeGVjdXRhYmxlQXJ0aWZhY3QSNy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJldm9rZUV4ZWN1dGFibGVBcnRpZmFjdFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUV4ZWN1dGFibGVBcnRpZmFjdFJlc3BvbnNlIkLC8xg+CAMQATABOiwKKHNhYXMubW9kdWxlLmV4ZWN1dGFibGVfYXJ0aWZhY3RfZGVjaXNpb24QAkABSAdQBFgCYAESxQEKHUV4Y2hhbmdlRGVsZWdhdGVkUmVhZEF1ZGllbmNlEjwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGNoYW5nZURlbGVnYXRlZFJlYWRBdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARLPAQoiRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZRJBLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARKnAQocQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2VzcxI1LnNhYXMuYWNjb3VudHMudjEuQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2Vzc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkNoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEqoBCh1MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9ucxI2LnNhYXMuYWNjb3VudHMudjEuTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Gjcuc2Fhcy5hY2NvdW50cy52MS5MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESgAEKC1BsYWNlUmVjb3JkEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQbGFjZVJlY29yZFJlcXVlc3QaKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpFbnF1ZXVlSm9iEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbnF1ZXVlSm9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESegoJQ2xhaW1Kb2JzEiguc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXF1ZXN0Gikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEoMBCgxIZWFydGJlYXRKb2ISKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYQoGQWNrSm9iEiUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBY2tKb2JSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYwoHTmFja0pvYhImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTmFja0pvYlJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpOb3RpZnlVc2VyEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVOb3RpZnlVc2VyUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESrQEKD05vdGlmeU9yZ0FkbWlucxIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUub3JnX2FkbWluc19ub3RpZmllZBACQAFIB1ADWANgARKMAQoPUmVxdWVzdEFwcHJvdmFsEi4uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnUKC0dldEFwcHJvdmFsEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVHZXRBcHByb3ZhbFJlcXVlc3QaIC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAEScQoOQ2FuY2VsQXBwcm92YWwSLS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUNhbmNlbEFwcHJvdmFsUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnEKDkVtaXRBdWRpdEV2ZW50Ei0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbWl0QXVkaXRFdmVudFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLDAQoWRGVjbGFyZUF1ZGl0RXZlbnRUeXBlcxI1LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZSI6wvMYNggDEAEwATokCiBzYWFzLm1vZHVsZS5hdWRpdF90eXBlc19kZWNsYXJlZBACQAFIB1ADWANgARKeAQoVTGlzdFN1YmplY3RWaXNpYmlsaXR5EjQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEo4BChNGZXRjaERhdGFzb3VyY2VCbG9iEiwuc2Fhcy5hY2NvdW50cy52MS5GZXRjaERhdGFzb3VyY2VCbG9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlQmxvYkNodW5rIhuIAgHC8xgUCAMQATABOgIQAUABSAdQA1gDYAEwARKOAQoURmV0Y2hEYXRhc291cmNlRmlsZXMSLS5zYWFzLmFjY291bnRzLnYxLkZldGNoRGF0YXNvdXJjZUZpbGVzUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABMAEStgEKFk1pbnRNb2R1bGVSZWdpc3RyYXRpb24SLy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50UmVnaXN0cmF0aW9uUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUucmVnaXN0cmF0aW9uX21pbnRlZBACQAFIB1AEWARgARK+AQoYTWludFNvbHV0aW9uUmVnaXN0cmF0aW9uEjEuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXNwb25zZSI7wvMYNwgDEAEwATolCiFzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl9taW50ZWQQAkABSAdQBFgEYAESswEKFU1pbnRNb2R1bGVXb3JrQ29udGV4dBIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUud29ya19jb250ZXh0X21pbnRlZBACQAFIB1AEWARgARLHAQoaTWludE1vZHVsZU9wZXJhdGlvbkNvbnRleHQSMy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRPcGVyYXRpb25Db250ZXh0UmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludE9wZXJhdGlvbkNvbnRleHRSZXNwb25zZSI+wvMYOggDEAEwATooCiRzYWFzLm1vZHVsZS5vcGVyYXRpb25fY29udGV4dF9taW50ZWQQAkABSAdQBFgEYAESzgEKGk1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0Ejkuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50U291cmNlT3BlcmF0aW9uQ29udGV4dFJlcXVlc3QaOi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24udXNlZBACQAFIB1AEWARgARKDAQoMUHVibGlzaEV2ZW50Eisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKCVN1YnNjcmliZRIoLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJrCgtVbnN1YnNjcmliZRIqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlVW5zdWJzY3JpYmVSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESkgEKEUxpc3RTdWJzY3JpcHRpb25zEjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QaMS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUxpc3RTdWJzY3JpcHRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARKDAQoMUmVwbGF5RXZlbnRzEisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABQr8BChRjb20uc2Fhcy5hY2NvdW50cy52MUIXTW9kdWxlQ2FwYWJpbGl0aWVzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_saas_accounts_v1_work_contexts, file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_accounts_v1_module_registration, file_saas_events_v1_events, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); + fileDesc("CipzYWFzL2FjY291bnRzL3YxL21vZHVsZV9jYXBhYmlsaXRpZXMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoXTW9kdWxlRW5xdWV1ZUpvYlJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIpCgNqb2IYAiABKAsyFC5zYWFzLmpvYnMudjEuTmV3Sm9iQga6SAPIAQEidgoYTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlEhgKBmpvYl9pZBgBIAEoCUIIukgFcgOwAQESQAoLZGlzcG9zaXRpb24YAiABKA4yIy5zYWFzLmpvYnMudjEuSm9iRW5xdWV1ZURpc3Bvc2l0aW9uQga6SAPIAQEiygEKFk1vZHVsZUNsYWltSm9ic1JlcXVlc3QSLgoFcXVldWUYASABKAlCH7pIHHIaEAEYgAEyE15bYS16XVthLXowLTlfLi1dKiQSHQoJd29ya2VyX2lkGAIgASgJQgq6SAdyBRABGP8BEhgKBWxpbWl0GAMgASgNQgm6SAYqBBhkKAESRwoObGVhc2VfZHVyYXRpb24YBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IkIKF01vZHVsZUNsYWltSm9ic1Jlc3BvbnNlEicKBGpvYnMYASADKAsyGS5zYWFzLmpvYnMudjEuSm9iRW52ZWxvcGUilwEKGU1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARJCCglleHRlbnNpb24YAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CFLpIEcgBAaoBCyIDCJAcMgQQwIQ9IksKGk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlEi0KBWxlYXNlGAEgASgLMhYuc2Fhcy5qb2JzLnYxLkpvYkxlYXNlQga6SAPIAQEipgEKE01vZHVsZUFja0pvYlJlcXVlc3QSNgoFbGVhc2UYASABKAsyHy5zYWFzLmpvYnMudjEuSm9iTGVhc2VSZWZlcmVuY2VCBrpIA8gBARI3Cg5leGVjdXRpb25fa2luZBgCIAEoCUIfukgcchoYQDIWXiR8XlthLXpdW2EtejAtOV8uLV0qJBIeCgxleGVjdXRpb25faWQYAyABKAlCCLpIBXIDGP8BIsIBChRNb2R1bGVOYWNrSm9iUmVxdWVzdBI2CgVsZWFzZRgBIAEoCzIfLnNhYXMuam9icy52MS5Kb2JMZWFzZVJlZmVyZW5jZUIGukgDyAEBEjEKB2ZhaWx1cmUYAiABKAsyGC5zYWFzLmpvYnMudjEuSm9iRmFpbHVyZUIGukgDyAEBEhEKCXJldHJ5YWJsZRgDIAEoCBIsCghyZXRyeV9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi9gEKF01vZHVsZU5vdGlmeVVzZXJSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESGQoHdXNlcl9pZBgCIAEoCUIIukgFcgOwAQESGQoFdGl0bGUYAyABKAlCCrpIB3IFEAEY5gcSFgoEYm9keRgEIAEoCUIIukgFcgMYgEASFQoEdHlwZRgFIAEoCUIHukgEcgIYQBIcCgphY3Rpb25fdXJsGAYgASgJQgi6SAVyAxiAEBIbCghjYXRlZ29yeRgHIAEoCUIJukgGcgQQARggEiEKD2lkZW1wb3RlbmN5X2tleRgIIAEoCUIIukgFcgMY/wEiRgoYTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlEhcKD25vdGlmaWNhdGlvbl9pZBgBIAEoCRIRCglkZWxpdmVyZWQYAiABKAgi4AEKHE1vZHVsZU5vdGlmeU9yZ0FkbWluc1JlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIZCgV0aXRsZRgCIAEoCUIKukgHcgUQARjmBxIWCgRib2R5GAMgASgJQgi6SAVyAxiAQBIVCgR0eXBlGAQgASgJQge6SARyAhhAEhwKCmFjdGlvbl91cmwYBSABKAlCCLpIBXIDGIAQEhsKCGNhdGVnb3J5GAYgASgJQgm6SAZyBBABGCASIQoPaWRlbXBvdGVuY3lfa2V5GAcgASgJQgi6SAVyAxj/ASIyCh1Nb2R1bGVOb3RpZnlPcmdBZG1pbnNSZXNwb25zZRIRCglkZWxpdmVyZWQYASABKAgiYwoUTW9kdWxlQXBwcm92YWxQb2xpY3kSFwoGcXVvcnVtGAEgASgNQge6SAQqAhhkEh4KDGFwcHJvdmVyX3NldBgCIAMoCUIIukgFkgECEGQSEgoKYWxsb3dfc2VsZhgDIAEoCCKbAQoPTW9kdWxlUmVzdW1lUmVmEi4KBXF1ZXVlGAEgASgJQh+6SBxyGhABGIABMhNeW2Etel1bYS16MC05Xy4tXSokEi4KBXRvcGljGAIgASgJQh+6SBxyGhABGP8BMhNeW2Etel1bYS16MC05Xy4tXSokEigKB3BheWxvYWQYAyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0IpYDChxNb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHAoIcmVzb3VyY2UYAiABKAlCCrpIB3IFEAEYgAESGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIABEigKB3N1YmplY3QYBCABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiAKDHJlcXVlc3RlZF9ieRgFIAEoCUIKukgHcgUQARj/ARI2CgZwb2xpY3kYBiABKAsyJi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsUG9saWN5Ej0KCnJlc3VtZV9yZWYYByABKAsyIS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJlc3VtZVJlZkIGukgDyAEBEi4KCmV4cGlyZXNfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCI+Ch1Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZRIdCgthcHByb3ZhbF9pZBgBIAEoCUIIukgFcgOwAQEiUwoYTW9kdWxlR2V0QXBwcm92YWxSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESHQoLYXBwcm92YWxfaWQYAiABKAlCCLpIBXIDsAEBIvUCCg5Nb2R1bGVBcHByb3ZhbBIKCgJpZBgBIAEoCRIOCgZ0ZW5hbnQYAiABKAkSEAoIcmVzb3VyY2UYAyABKAkSDgoGYWN0aW9uGAQgASgJEigKB3N1YmplY3QYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHJlcXVlc3RlZF9ieRgGIAEoCRIOCgZxdW9ydW0YByABKA0SDQoFc3RhdGUYCCABKAkSNQoKcmVzdW1lX3JlZhgJIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlUmVzdW1lUmVmEi4KCmV4cGlyZXNfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2VzY2FsYXRlX2F0GAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAwgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJwChtNb2R1bGVDYW5jZWxBcHByb3ZhbFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARIdCgthcHByb3ZhbF9pZBgCIAEoCUIIukgFcgOwAQESGAoGcmVhc29uGAMgASgJQgi6SAVyAxiACCLuAQobTW9kdWxlRW1pdEF1ZGl0RXZlbnRSZXF1ZXN0Eg4KBnRlbmFudBgBIAEoCRIeCgpldmVudF90eXBlGAIgASgJQgq6SAdyBRABGIABEhkKBWFjdG9yGAMgASgJQgq6SAdyBRABGP8BEhwKCHNvbHV0aW9uGAQgASgJQgq6SAdyBRABGIABEhoKCGVudHJ5X2lkGAUgASgJQgi6SAVyAxj/ARInCgZmaWVsZHMYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EiEKD2lkZW1wb3RlbmN5X2tleRgHIAEoCUIIukgFcgMY/wEinwEKG01vZHVsZUF1ZGl0RmllbGREZWNsYXJhdGlvbhIXCgRuYW1lGAEgASgJQgm6SAZyBBABGEASQAoEa2luZBgCIAEoDjImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlQXVkaXRGaWVsZEtpbmRCCrpIB4IBBBABIAASGAoGdmFsdWVzGAMgAygJQgi6SAWSAQIQQBILCgNwaWkYBCABKAgi7wEKH01vZHVsZUF1ZGl0RXZlbnRUeXBlRGVjbGFyYXRpb24SGAoEdHlwZRgBIAEoCUIKukgHcgUQARiAARIdCgtkZXNjcmlwdGlvbhgCIAEoCUIIukgFcgMYgAgSRwoGZmllbGRzGAMgAygLMi0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEZpZWxkRGVjbGFyYXRpb25CCLpIBZIBAhAgEkoKCnZpc2liaWxpdHkYBCABKA4yLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUF1ZGl0RXZlbnRWaXNpYmlsaXR5Qgi6SAWCAQIQASKzAQojTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QSPgoGcHJlZml4GAEgASgJQi66SCtyKRABGIABMiJeW2EtejAtOV0oPzpbYS16MC05Xy1dKlthLXowLTldKT8kEkwKBXR5cGVzGAIgAygLMjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBdWRpdEV2ZW50VHlwZURlY2xhcmF0aW9uQgq6SAeSAQQIARBAIl0KJE1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZRIWCg5hZG1pdHRlZF90eXBlcxgBIAMoCRIdChVuYW1lc3BhY2VzX3Rha2VuX292ZXIYAiADKAkibQoWU3ViamVjdFZpc2liaWxpdHlHcmFudBIjChJ2aXNpYmxlX3N1YmplY3RfaWQYASABKAlCB7pIBHICEAESLgoKZXhwaXJlc19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiYwoiTW9kdWxlTGlzdFN1YmplY3RWaXNpYmlsaXR5UmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEiMKEXZpZXdlcl9zdWJqZWN0X2lkGAIgASgJQgi6SAVyA7ABASJfCiNNb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZRI4CgZncmFudHMYASADKAsyKC5zYWFzLmFjY291bnRzLnYxLlN1YmplY3RWaXNpYmlsaXR5R3JhbnQiVwoaRmV0Y2hEYXRhc291cmNlQmxvYlJlcXVlc3QSGwoJc291cmNlX2lkGAEgASgJQgi6SAVyA7ABARIcCghibG9iX3NoYRgCIAEoCUIKukgHcgUQARj/ASJSChhGZXRjaERhdGFzb3VyY2VCbG9iQ2h1bmsSDAoEZGF0YRgBIAEoDBISCgp0b3RhbF9zaXplGAIgASgDEhQKDGNvbnRlbnRfdHlwZRgDIAEoCSJSChFEYXRhc291cmNlRmlsZVJlZhIbCgdpdGVtX2lkGAEgASgJQgq6SAdyBRABGIAgEiAKDGl0ZW1fdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ASKYAQobRmV0Y2hEYXRhc291cmNlRmlsZXNSZXF1ZXN0EhsKCXNvdXJjZV9pZBgBIAEoCUIIukgFcgOwAQESGwoHdmVyc2lvbhgCIAEoCUIKukgHcgUQARj/ARI/CgVmaWxlcxgDIAMoCzIjLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUZpbGVSZWZCC7pICJIBBQgBEOgHIosBChREYXRhc291cmNlUHJvdmVuYW5jZRIRCglzb3VyY2VfaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEhgKEGJvdW5kYXJ5X25vZGVfaWQYAyABKAkSDwoHdmVyc2lvbhgEIAEoCRIPCgdpdGVtX2lkGAUgASgJEhQKDGl0ZW1fdmVyc2lvbhgGIAEoCSLAAQoVRGF0YXNvdXJjZUl0ZW1SZWFkZXJzEjsKBWJhc2lzGAEgASgOMiwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnNCYXNpcxIQCgh1c2VyX2lkcxgCIAMoCRIRCglncm91cF9pZHMYAyADKAkSGAoQYm91bmRhcnlfcmVhZGVycxgEIAEoCBIWCg51bm1hcHBlZF9jb3VudBgFIAEoBRITCgthY2xfdmVyc2lvbhgGIAEoCSK+AQoURGF0YXNvdXJjZUZpbGVIZWFkZXISOgoKcHJvdmVuYW5jZRgBIAEoCzImLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZlbmFuY2USDAoEcGF0aBgCIAEoCRIUCgxjb250ZW50X3R5cGUYAyABKAkSDAoEc2l6ZRgEIAEoAxI4CgdyZWFkZXJzGAUgASgLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlSXRlbVJlYWRlcnMibgoZRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZRI4CgZoZWFkZXIYASABKAsyJi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VGaWxlSGVhZGVySAASDgoEZGF0YRgCIAEoDEgAQgcKBWZyYW1lIm4KGU1vZHVsZVB1Ymxpc2hFdmVudFJlcXVlc3QSGAoGdGVuYW50GAEgASgJQgi6SAVyA7ABARI3CghlbnZlbG9wZRgCIAEoCzIdLnNhYXMuZXZlbnRzLnYxLkV2ZW50RW52ZWxvcGVCBrpIA8gBASI4ChpNb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZRIaCghldmVudF9pZBgBIAEoCUIIukgFcgOwAQEiyAEKFk1vZHVsZVN1YnNjcmliZVJlcXVlc3QSSwoMdHlwZV9wYXR0ZXJuGAEgASgJQjW6SDJyMBABGIABMileW2Etel1bYS16MC05XSooPzpcLlthLXowLTldKykqKD86XC5cKik/JBIuCgVxdWV1ZRgCIAEoCUIfukgcchoQARiAATITXlthLXpdW2EtejAtOV8uLV0qJBIxCghkZWxpdmVyeRgDIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeSLJAQoSTW9kdWxlU3Vic2NyaXB0aW9uEgoKAmlkGAEgASgJEh8KF3N1YnNjcmliZXJfcHJpbmNpcGFsX2lkGAIgASgJEhQKDHR5cGVfcGF0dGVybhgDIAEoCRINCgVxdWV1ZRgEIAEoCRIxCghkZWxpdmVyeRgFIAEoDjIfLnNhYXMuYWNjb3VudHMudjEuRXZlbnREZWxpdmVyeRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJVChdNb2R1bGVTdWJzY3JpYmVSZXNwb25zZRI6CgxzdWJzY3JpcHRpb24YASABKAsyJC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVN1YnNjcmlwdGlvbiI9ChhNb2R1bGVVbnN1YnNjcmliZVJlcXVlc3QSIQoPc3Vic2NyaXB0aW9uX2lkGAEgASgJQgi6SAVyA7ABASIgCh5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QiXgofTW9kdWxlTGlzdFN1YnNjcmlwdGlvbnNSZXNwb25zZRI7Cg1zdWJzY3JpcHRpb25zGAEgAygLMiQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVTdWJzY3JpcHRpb24iegoZTW9kdWxlUmVwbGF5RXZlbnRzUmVxdWVzdBIYCgZ0ZW5hbnQYASABKAlCCLpIBXIDsAEBEhgKBHR5cGUYAiABKAlCCrpIB3IFEAEYgAESKQoFc2luY2UYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIjEKGk1vZHVsZVJlcGxheUV2ZW50c1Jlc3BvbnNlEhMKC3JlZGVsaXZlcmVkGAEgASgFItMCChhSZWFkYWJsZVNvdXJjZUNvbGxlY3Rpb24SEQoJc291cmNlX2lkGAEgASgJEhMKC2JvdW5kYXJ5X2lkGAIgASgJEg4KBm9yaWdpbhgDIAEoCRIRCgljb250YWluZXIYBCABKAkSCwoDcmVmGAUgASgJEg0KBXBhdGhzGAYgAygJEhYKDmJvdW5kYXJ5X2xhYmVsGAcgASgJEj4KEGdyYW50X2Rpc2Nsb3N1cmUYCCABKA4yJC5zYWFzLmFjY291bnRzLnYxLk1ldGFkYXRhRGlzY2xvc3VyZRI+CgtyZWFkX2dyYW50cxgJIAMoCzIpLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVDb2xsZWN0aW9uR3JhbnQSOAoEc3luYxgKIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuQ29sbGVjdGlvblN5bmNQcm92ZW5hbmNlIrABChdSZWFkYWJsZUNvbGxlY3Rpb25HcmFudBIVCg1zdWJqZWN0X2xhYmVsGAEgASgJEhQKDHN1YmplY3Rfa2luZBgCIAEoCRIRCglyb2xlX25hbWUYAyABKAkSEgoKc2NvcGVfcGF0aBgEIAEoCRIRCglpbmhlcml0ZWQYBSABKAgSLgoKZXhwaXJlc19hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiqQIKGENvbGxlY3Rpb25TeW5jUHJvdmVuYW5jZRIwCgVzdGFnZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1N0YWdlEiYKAmF0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIQCghyZXZpc2lvbhgDIAEoCRIPCgd0cmlnZ2VyGAQgASgJEkIKFHJlcXVlc3Rlcl9kaXNjbG9zdXJlGAUgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5NZXRhZGF0YURpc2Nsb3N1cmUSMAoMcmVxdWVzdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIaChJyZXF1ZXN0ZWRfYnlfbGFiZWwYByABKAkiYwokTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Eh0KCXBhZ2Vfc2l6ZRgBIAEoBUIKukgHGgUY6AcoABIcCgpwYWdlX3Rva2VuGAIgASgJQgi6SAVyAxiAECKBAQolTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXNwb25zZRI/Cgtjb2xsZWN0aW9ucxgBIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuUmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSLpAQoYTW9kdWxlUGxhY2VSZWNvcmRSZXF1ZXN0EhgKBnRlbmFudBgBIAEoCUIIukgFcgOwAQESPQoKc2NvcGVfcGF0aBgCIAEoCUIpukgmciQQARiABDIdXlthLXowLTlfXSsoPzpcLlthLXowLTlfXSspKiQSFwoEa2luZBgDIAEoCUIJukgGcgQQARhAEhcKBWxhYmVsGAQgASgJQgi6SAVyAxj/ARIhCg1yZXNvdXJjZV90eXBlGAUgASgJQgq6SAdyBRABGIABEh8KC3Jlc291cmNlX2lkGAYgASgJQgq6SAdyBRABGIAEIjYKGU1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2USGQoHbm9kZV9pZBgBIAEoCUIIukgFcgOwAQEihQEKI0NoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXF1ZXN0EiEKDXJlc291cmNlX3R5cGUYASABKAlCCrpIB3IFEAEYgAISHwoLcmVzb3VyY2VfaWQYAiABKAlCCrpIB3IFEAEYgAQSGgoGYWN0aW9uGAMgASgJQgq6SAdyBRABGIACIk4KJENoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZRIPCgdhbGxvd2VkGAEgASgIEhUKDXNjb3BlX25vZGVfaWQYAiABKAkifAoqTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRSZWFkQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAIigAMKL01vZHVsZUV4Y2hhbmdlRGVsZWdhdGVkT3BlcmF0aW9uQXVkaWVuY2VSZXF1ZXN0Eh4KCmJpbmRpbmdfaWQYASABKAlCCrpIB3IFEAEYgAESLAoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUIJukgGcgQYgIACEg4KBmxvb2t1cBgDIAEoCBIiCg1kZWxlZ2F0aW9uX2lkGAQgASgJQgu6SAjYAQFyA7ABATrKAbpIxgEawwEKM2V4Y2hhbmdlX2RlbGVnYXRlZF9vcGVyYXRpb25fYXVkaWVuY2Uub25lX2F1dGhvcml0eRJGZXhhY3RseSBvbmUgb2YgcGFyZW50X3dvcmtfY29udGV4dF90b2tlbiBhbmQgZGVsZWdhdGlvbl9pZCBpcyByZXF1aXJlZBpEKHRoaXMucGFyZW50X3dvcmtfY29udGV4dF90b2tlbiAhPSAnJykgIT0gKHRoaXMuZGVsZWdhdGlvbl9pZCAhPSAnJykifQoaRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3QSFwoEa2luZBgBIAEoCUIJukgGcgQQARhAEhgKBG5hbWUYAiABKAlCCrpIB3IFEAEYgAQSLAoGZGlnZXN0GAMgASgJQhy6SBlyFzIVXnNoYTI1NjpbMC05YS1mXXs2NH0kIuIBChpFeGVjdXRhYmxlQXJ0aWZhY3RJZGVudGl0eRIaCgZzY2hlbWEYASABKAlCCrpIB3IFEAEYgAESGgoGc291cmNlGAIgASgJQgq6SAdyBRABGIAEEhwKB3N1YmplY3QYAyABKAxCC7pICHoGEAEYgIAEEkoKCWNvbnRyYWN0cxgEIAMoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0Q29udHJhY3RCCbpIBpIBAxCAAhIiChFleHBlY3RlZF9yZXZpc2lvbhgFIAEoA0IHukgEIgIoACLbAQofTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KCXBvbGljeV9pZBgDIAEoCUIKukgHcgUQARiAARJGCghpZGVudGl0eRgEIAEoCzIsLnNhYXMuYWNjb3VudHMudjEuRXhlY3V0YWJsZUFydGlmYWN0SWRlbnRpdHlCBrpIA8gBASKZAQolTW9kdWxlUmV2b2tlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAEgASgJQgu6SAhyBhABGICAAhIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEh0KC2FwcHJvdmFsX2lkGAMgASgJQgi6SAVyA7ABASJ9CiBNb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZRIWCg5zY2hlbWFfdmVyc2lvbhgBIAEoCRIWCg5xdWFsaWZpZWRfbmFtZRgCIAEoCRIOCgZkaWdlc3QYAyABKAkSGQoRZXhwZWN0ZWRfcmV2aXNpb24YBCABKAMidQogTW9kdWxlQ3VycmVudEluc3RhbGxhdGlvblJlcXVlc3QSLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgBIAEoCUILukgIcgYQARiAgAISIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASKbAQohTW9kdWxlQ3VycmVudEluc3RhbGxhdGlvblJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIRCgl0ZW5hbnRfaWQYAiABKAkSGwoJdGFyZ2V0X2lkGAQgASgJQgi6SAVyA7ABARISCgpiaW5kaW5nX2lkGAUgASgJSgQIAxAEUhNzb2x1dGlvbl9pZGVudGlmaWVyKrgCChRNb2R1bGVBdWRpdEZpZWxkS2luZBInCiNNT0RVTEVfQVVESVRfRklFTERfS0lORF9VTlNQRUNJRklFRBAAEiIKHk1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklORxABEiAKHE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1VVSUQQAhIfChtNT0RVTEVfQVVESVRfRklFTERfS0lORF9JTlQQAxIiCh5NT0RVTEVfQVVESVRfRklFTERfS0lORF9OVU1CRVIQBBIgChxNT0RVTEVfQVVESVRfRklFTERfS0lORF9CT09MEAUSIAocTU9EVUxFX0FVRElUX0ZJRUxEX0tJTkRfRU5VTRAGEigKJE1PRFVMRV9BVURJVF9GSUVMRF9LSU5EX1NUUklOR19BUlJBWRAHKqEBChpNb2R1bGVBdWRpdEV2ZW50VmlzaWJpbGl0eRItCilNT0RVTEVfQVVESVRfRVZFTlRfVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEigKJE1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX1RFTkFOVBABEioKJk1PRFVMRV9BVURJVF9FVkVOVF9WSVNJQklMSVRZX0VYVEVSTkFMEAIq3AEKGkRhdGFzb3VyY2VJdGVtUmVhZGVyc0Jhc2lzEi0KKURBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1VOU1BFQ0lGSUVEEAASLAooREFUQVNPVVJDRV9JVEVNX1JFQURFUlNfQkFTSVNfVFJBTlNMQVRFRBABEi8KK0RBVEFTT1VSQ0VfSVRFTV9SRUFERVJTX0JBU0lTX1NPVVJDRV9TQ09QRUQQAhIwCixEQVRBU09VUkNFX0lURU1fUkVBREVSU19CQVNJU19VTlRSQU5TTEFUQUJMRRADKmkKDUV2ZW50RGVsaXZlcnkSHgoaRVZFTlRfREVMSVZFUllfVU5TUEVDSUZJRUQQABIcChhFVkVOVF9ERUxJVkVSWV9VTk9SREVSRUQQARIaChZFVkVOVF9ERUxJVkVSWV9PUkRFUkVEEAIqfgoSTWV0YWRhdGFEaXNjbG9zdXJlEiMKH01FVEFEQVRBX0RJU0NMT1NVUkVfVU5TUEVDSUZJRUQQABIhCh1NRVRBREFUQV9ESVNDTE9TVVJFX0RJU0NMT1NFRBABEiAKHE1FVEFEQVRBX0RJU0NMT1NVUkVfV0lUSEhFTEQQAipcCg9Tb3VyY2VTeW5jU3RhZ2USIQodU09VUkNFX1NZTkNfU1RBR0VfVU5TUEVDSUZJRUQQABImCiJTT1VSQ0VfU1lOQ19TVEFHRV9DSEFOR0VTX0VOUVVFVUVEEAEypykKGU1vZHVsZUNhcGFiaWxpdGllc1NlcnZpY2USmwEKFkdldEN1cnJlbnRJbnN0YWxsYXRpb24SMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUN1cnJlbnRJbnN0YWxsYXRpb25SZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDdXJyZW50SW5zdGFsbGF0aW9uUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1AEWANgARLGAQoZQXBwcm92ZUV4ZWN1dGFibGVBcnRpZmFjdBIxLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVxdWVzdBoyLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhlY3V0YWJsZUFydGlmYWN0UmVzcG9uc2UiQsLzGD4IAxABMAE6LAooc2Fhcy5tb2R1bGUuZXhlY3V0YWJsZV9hcnRpZmFjdF9kZWNpc2lvbhACQAFIB1AEWAJgARKeAQobQXV0aG9yaXplRXhlY3V0YWJsZUFydGlmYWN0EjEuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGVjdXRhYmxlQXJ0aWZhY3RSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUARYAmABEssBChhSZXZva2VFeGVjdXRhYmxlQXJ0aWZhY3QSNy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVJldm9rZUV4ZWN1dGFibGVBcnRpZmFjdFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUV4ZWN1dGFibGVBcnRpZmFjdFJlc3BvbnNlIkLC8xg+CAMQATABOiwKKHNhYXMubW9kdWxlLmV4ZWN1dGFibGVfYXJ0aWZhY3RfZGVjaXNpb24QAkABSAdQBFgCYAESxQEKHUV4Y2hhbmdlRGVsZWdhdGVkUmVhZEF1ZGllbmNlEjwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFeGNoYW5nZURlbGVnYXRlZFJlYWRBdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARLPAQoiRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZRJBLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRXhjaGFuZ2VEZWxlZ2F0ZWRPcGVyYXRpb25BdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0IkHC8xg9CAMQATABOisKJ3NhYXMubW9kdWxlLmRlbGVnYXRlZF9hdWRpZW5jZV9leGNoYW5nZRAEQAFIB1AEWARgARKnAQocQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2VzcxI1LnNhYXMuYWNjb3VudHMudjEuQ2hlY2tXb3JrQ29udGV4dFJlY29yZEFjY2Vzc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkNoZWNrV29ya0NvbnRleHRSZWNvcmRBY2Nlc3NSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEqoBCh1MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9ucxI2LnNhYXMuYWNjb3VudHMudjEuTGlzdFJlYWRhYmxlU291cmNlQ29sbGVjdGlvbnNSZXF1ZXN0Gjcuc2Fhcy5hY2NvdW50cy52MS5MaXN0UmVhZGFibGVTb3VyY2VDb2xsZWN0aW9uc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESgAEKC1BsYWNlUmVjb3JkEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQbGFjZVJlY29yZFJlcXVlc3QaKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZVBsYWNlUmVjb3JkUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpFbnF1ZXVlSm9iEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbnF1ZXVlSm9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRW5xdWV1ZUpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESegoJQ2xhaW1Kb2JzEiguc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXF1ZXN0Gikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVDbGFpbUpvYnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEoMBCgxIZWFydGJlYXRKb2ISKy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlcXVlc3QaLC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUhlYXJ0YmVhdEpvYlJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYQoGQWNrSm9iEiUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVBY2tKb2JSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESYwoHTmFja0pvYhImLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTmFja0pvYlJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJ9CgpOb3RpZnlVc2VyEikuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVOb3RpZnlVc2VyUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5VXNlclJlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESrQEKD05vdGlmeU9yZ0FkbWlucxIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTm90aWZ5T3JnQWRtaW5zUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUub3JnX2FkbWluc19ub3RpZmllZBACQAFIB1ADWANgARKMAQoPUmVxdWVzdEFwcHJvdmFsEi4uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXF1ZXN0QXBwcm92YWxSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnUKC0dldEFwcHJvdmFsEiouc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVHZXRBcHByb3ZhbFJlcXVlc3QaIC5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUFwcHJvdmFsIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAEScQoOQ2FuY2VsQXBwcm92YWwSLS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUNhbmNlbEFwcHJvdmFsUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnEKDkVtaXRBdWRpdEV2ZW50Ei0uc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVFbWl0QXVkaXRFdmVudFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLDAQoWRGVjbGFyZUF1ZGl0RXZlbnRUeXBlcxI1LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlRGVjbGFyZUF1ZGl0RXZlbnRUeXBlc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZURlY2xhcmVBdWRpdEV2ZW50VHlwZXNSZXNwb25zZSI6wvMYNggDEAEwATokCiBzYWFzLm1vZHVsZS5hdWRpdF90eXBlc19kZWNsYXJlZBACQAFIB1ADWANgARKeAQoVTGlzdFN1YmplY3RWaXNpYmlsaXR5EjQuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3ViamVjdFZpc2liaWxpdHlSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEo4BChNGZXRjaERhdGFzb3VyY2VCbG9iEiwuc2Fhcy5hY2NvdW50cy52MS5GZXRjaERhdGFzb3VyY2VCbG9iUmVxdWVzdBoqLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlQmxvYkNodW5rIhuIAgHC8xgUCAMQATABOgIQAUABSAdQA1gDYAEwARKOAQoURmV0Y2hEYXRhc291cmNlRmlsZXMSLS5zYWFzLmFjY291bnRzLnYxLkZldGNoRGF0YXNvdXJjZUZpbGVzUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuRmV0Y2hEYXRhc291cmNlRmlsZXNGcmFtZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABMAEStgEKFk1pbnRNb2R1bGVSZWdpc3RyYXRpb24SLy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50UmVnaXN0cmF0aW9uUmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUucmVnaXN0cmF0aW9uX21pbnRlZBACQAFIB1AEWARgARK+AQoYTWludFNvbHV0aW9uUmVnaXN0cmF0aW9uEjEuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbk1pbnRSZWdpc3RyYXRpb25SZXNwb25zZSI7wvMYNwgDEAEwATolCiFzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl9taW50ZWQQAkABSAdQBFgEYAESswEKFU1pbnRNb2R1bGVXb3JrQ29udGV4dBIuLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludFdvcmtDb250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5tb2R1bGUud29ya19jb250ZXh0X21pbnRlZBACQAFIB1AEWARgARLHAQoaTWludE1vZHVsZU9wZXJhdGlvbkNvbnRleHQSMy5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRPcGVyYXRpb25Db250ZXh0UmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuTW9kdWxlTWludE9wZXJhdGlvbkNvbnRleHRSZXNwb25zZSI+wvMYOggDEAEwATooCiRzYWFzLm1vZHVsZS5vcGVyYXRpb25fY29udGV4dF9taW50ZWQQAkABSAdQBFgEYAESzgEKGk1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0Ejkuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVNaW50U291cmNlT3BlcmF0aW9uQ29udGV4dFJlcXVlc3QaOi5zYWFzLmFjY291bnRzLnYxLk1vZHVsZU1pbnRTb3VyY2VPcGVyYXRpb25Db250ZXh0UmVzcG9uc2UiOcLzGDUIAxABMAE6Iwofc2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24udXNlZBACQAFIB1AEWARgARKDAQoMUHVibGlzaEV2ZW50Eisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVQdWJsaXNoRXZlbnRSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKCVN1YnNjcmliZRIoLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlU3Vic2NyaWJlUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARJrCgtVbnN1YnNjcmliZRIqLnNhYXMuYWNjb3VudHMudjEuTW9kdWxlVW5zdWJzY3JpYmVSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESkgEKEUxpc3RTdWJzY3JpcHRpb25zEjAuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVMaXN0U3Vic2NyaXB0aW9uc1JlcXVlc3QaMS5zYWFzLmFjY291bnRzLnYxLk1vZHVsZUxpc3RTdWJzY3JpcHRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARKDAQoMUmVwbGF5RXZlbnRzEisuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXF1ZXN0Giwuc2Fhcy5hY2NvdW50cy52MS5Nb2R1bGVSZXBsYXlFdmVudHNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABQr8BChRjb20uc2Fhcy5hY2NvdW50cy52MUIXTW9kdWxlQ2FwYWJpbGl0aWVzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_saas_accounts_v1_work_contexts, file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_accounts_v1_module_registration, file_saas_events_v1_events, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); /** * ModuleEnqueueJobRequest names the tenant the work belongs to and carries the @@ -2170,9 +2170,20 @@ export type ModuleCurrentInstallationResponse = Message<"saas.accounts.v1.Module tenantId: string; /** - * @generated from field: string solution_identifier = 3; + * The immutable target this installation was consented to: one continuous + * period of one binding's presence, never reused. + * + * @generated from field: string target_id = 4; + */ + targetId: string; + + /** + * The delivered presence binding whose period that target records. This is + * what an authority decision keys on; the alias is display only. + * + * @generated from field: string binding_id = 5; */ - solutionIdentifier: string; + bindingId: string; }; /** diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_pb.ts new file mode 100644 index 000000000..f979ae47e --- /dev/null +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_pb.ts @@ -0,0 +1,152 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/accounts/v1/solution_entitlements.proto (package saas.accounts.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file saas/accounts/v1/solution_entitlements.proto. + */ +export const file_saas_accounts_v1_solution_entitlements: GenFile = /*@__PURE__*/ + fileDesc("CixzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX2VudGl0bGVtZW50cy5wcm90bxIQc2Fhcy5hY2NvdW50cy52MSKnAQoTU29sdXRpb25FbnRpdGxlbWVudBIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEiQKEnJvb3Rfc2NvcGVfbm9kZV9pZBgDIAEoCUIIukgFcgOwAQESDwoHaGVhbHRoeRgEIAEoCBIbCgl0YXJnZXRfaWQYBSABKAlCCLpIBXIDsAEBSgQIARACUhNzb2x1dGlvbl9pZGVudGlmaWVyIowBCh9MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoKc3ViamVjdF9pZBgCIAEoCUIIukgFcgOwAQESHQoJcGFnZV9zaXplGAMgASgFQgq6SAcaBRj0AygAEhIKCnBhZ2VfdG9rZW4YBCABKAkieAogTGlzdFNvbHV0aW9uRW50aXRsZW1lbnRzUmVzcG9uc2USOwoMZW50aXRsZW1lbnRzGAEgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkVudGl0bGVtZW50EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCULBAQoUY29tLnNhYXMuYWNjb3VudHMudjFCGVNvbHV0aW9uRW50aXRsZW1lbnRzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate]); + +/** + * SolutionEntitlement is one installed solution a subject may use. It is + * deliberately a projection of the installation, not the installation: a + * consumer of this message renders a menu, and the owner of record, the + * succession set and the agent principal are no part of that. + * + * @generated from message saas.accounts.v1.SolutionEntitlement + */ +export type SolutionEntitlement = Message<"saas.accounts.v1.SolutionEntitlement"> & { + /** + * @generated from field: string installation_id = 2; + */ + installationId: string; + + /** + * The installation's authority-root scope node — the node the viewer's grant + * had to reach for this entitlement to exist. Carried so a consumer can say + * which boundary admitted a solution without a second lookup. + * + * @generated from field: string root_scope_node_id = 3; + */ + rootScopeNodeId: string; + + /** + * Whether the installation is healthy right now, resolved live from the same + * authority facts GetInstallation reports. + * + * A bool rather than a copy of InstallationHealth on purpose. A consumer of + * this message shows an unhealthy solution as unavailable and never renders + * the reason — an operator reads that from GetInstallation — so restating that + * enum here would be a second copy of a value set to keep in lockstep. This + * reduces it instead, by the one rule that survives the enum growing: anything + * that is not HEALTHY is not healthy. + * + * @generated from field: bool healthy = 4; + */ + healthy: boolean; + + /** + * The immutable solution target this entitlement is for — the key a consumer + * joins against its own registered set. + * + * A consumer resolves a route alias to a target through the host's applied + * presence state and then compares THIS id. That is what makes the alias-reuse + * transfer inexpressible: a replacement binding claiming a withdrawn alias + * resolves to its own target, which no installation of the predecessor names. + * + * The alias is deliberately absent. Carrying it here would let a consumer go + * back to joining on it, and it would be a copy of a value the presence + * snapshot already owns and may move. + * + * Deliberately no platform-administrator basis field. The scope union admits a + * platform super_admin for the action `read` alone, and using a solution is not + * a read, so that branch can never contribute an entitlement here — a field + * reporting it would be permanently false. A super_admin sees an organization's + * installations through GetInstallation / ListInstallations, not by appearing to + * be entitled to them. + * + * @generated from field: string target_id = 5; + */ + targetId: string; +}; + +/** + * Describes the message saas.accounts.v1.SolutionEntitlement. + * Use `create(SolutionEntitlementSchema)` to create a new message. + */ +export const SolutionEntitlementSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_entitlements, 0); + +/** + * @generated from message saas.accounts.v1.ListSolutionEntitlementsRequest + */ +export type ListSolutionEntitlementsRequest = Message<"saas.accounts.v1.ListSolutionEntitlementsRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * The subject whose entitlements to enumerate. A principal: a team's + * entitlements reach a member through the team grant, which the underlying + * union already resolves, so asking about a principal answers "everything this + * person may use" including what a team confers. + * + * @generated from field: string subject_id = 2; + */ + subjectId: string; + + /** + * Page bound. 0 -> server default; capped so an organization with many + * installations can never return an unbounded result in one call. + * + * @generated from field: int32 page_size = 3; + */ + pageSize: number; + + /** + * Opaque cursor from a previous response's next_page_token; empty for page one. + * + * @generated from field: string page_token = 4; + */ + pageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionEntitlementsRequest. + * Use `create(ListSolutionEntitlementsRequestSchema)` to create a new message. + */ +export const ListSolutionEntitlementsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_entitlements, 1); + +/** + * @generated from message saas.accounts.v1.ListSolutionEntitlementsResponse + */ +export type ListSolutionEntitlementsResponse = Message<"saas.accounts.v1.ListSolutionEntitlementsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.SolutionEntitlement entitlements = 1; + */ + entitlements: SolutionEntitlement[]; + + /** + * Set when more entitlements remain; pass it back as page_token. Empty on the + * last page. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionEntitlementsResponse. + * Use `create(ListSolutionEntitlementsResponseSchema)` to create a new message. + */ +export const ListSolutionEntitlementsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_entitlements, 2); + diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts new file mode 100644 index 000000000..89c9e8b3d --- /dev/null +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts @@ -0,0 +1,42 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/accounts/v1/solution_entitlements_service.proto (package saas.accounts.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { ListSolutionEntitlementsRequestSchema, ListSolutionEntitlementsResponseSchema } from "./solution_entitlements_pb"; +import { file_saas_accounts_v1_solution_entitlements } from "./solution_entitlements_pb"; +import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; + +/** + * Describes the file saas/accounts/v1/solution_entitlements_service.proto. + */ +export const file_saas_accounts_v1_solution_entitlements_service: GenFile = /*@__PURE__*/ + fileDesc("CjRzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX2VudGl0bGVtZW50c19zZXJ2aWNlLnByb3RvEhBzYWFzLmFjY291bnRzLnYxMroBChpTb2x1dGlvbkVudGl0bGVtZW50U2VydmljZRKbAQoYTGlzdFNvbHV0aW9uRW50aXRsZW1lbnRzEjEuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXF1ZXN0GjIuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABQsgBChRjb20uc2Fhcy5hY2NvdW50cy52MUIgU29sdXRpb25FbnRpdGxlbWVudHNTZXJ2aWNlUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_saas_accounts_v1_solution_entitlements, file_saas_policy_v1_options]); + +/** + * SolutionEntitlementService answers what one viewer may use inside one + * organization. Internal-tier: it takes both the organization and the subject as + * request fields, so it is an authority oracle about an arbitrary principal and + * only a caller holding the cluster-internal credential may ask. The caller that + * does is the auth-gateway, naming the tenant and the viewer it projected from a + * verified identity — never values a browser supplied. + * + * @generated from service saas.accounts.v1.SolutionEntitlementService + */ +export const SolutionEntitlementService: GenService<{ + /** + * ListSolutionEntitlements enumerates the installed solutions a subject may + * use, reading the installation set and the scope grant + share union in one + * org transaction so the two can never describe different moments. + * + * @generated from rpc saas.accounts.v1.SolutionEntitlementService.ListSolutionEntitlements + */ + listSolutionEntitlements: { + methodKind: "unary"; + input: typeof ListSolutionEntitlementsRequestSchema; + output: typeof ListSolutionEntitlementsResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_saas_accounts_v1_solution_entitlements_service, 0); + diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_pb.ts index 7d9759683..1266b0295 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_pb.ts @@ -4,7 +4,6 @@ import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; -import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; import type { Timestamp } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; import type { Message } from "@bufbuild/protobuf"; @@ -13,13 +12,12 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/solution_registry.proto. */ export const file_saas_accounts_v1_solution_registry: GenFile = /*@__PURE__*/ - fileDesc("CihzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5LnByb3RvEhBzYWFzLmFjY291bnRzLnYxIo0BChdTb2x1dGlvbkZyb250ZW5kQmluZGluZxIQCghyZXZpc2lvbhgBIAEoAxIQCghtYW5pZmVzdBgCIAEoCRIYChBjb250cmFjdF92ZXJzaW9uGAMgASgJEjQKEGxlYXNlX2V4cGlyZXNfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIqMBChZTb2x1dGlvbkJhY2tlbmRCaW5kaW5nEhAKCHJldmlzaW9uGAEgASgDEhAKCHVwc3RyZWFtGAIgASgJEhUKDXNlcnZpY2VfYWxpYXMYAyABKAkSGAoQY29udHJhY3RfdmVyc2lvbhgEIAEoCRI0ChBsZWFzZV9leHBpcmVzX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK9AwoUU29sdXRpb25SZWdpc3RyYXRpb24SEwoLc29sdXRpb25faWQYASABKAkSEQoJcHVibGlzaGVyGAIgASgJEhAKCHJldmlzaW9uGAMgASgDEjwKBnN0YXR1cxgEIAEoDjIsLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25SZWdpc3RyYXRpb25TdGF0dXMSQAoIZnJvbnRlbmQYBSABKAsyKS5zYWFzLmFjY291bnRzLnYxLlNvbHV0aW9uRnJvbnRlbmRCaW5kaW5nSACIAQESPgoHYmFja2VuZBgGIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25CYWNrZW5kQmluZGluZ0gBiAEBEi4KCnVwZGF0ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjYKDXRvbWJzdG9uZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSAKIAQESGAoQcnVudGltZV9ib3VuZGFyeRgJIAEoCUILCglfZnJvbnRlbmRCCgoIX2JhY2tlbmRCEAoOX3RvbWJzdG9uZWRfYXQiYQocU29sdXRpb25Gcm9udGVuZFJlZ2lzdHJhdGlvbhIdCghtYW5pZmVzdBgBIAEoCUILukgIcgYQAhiAgBASIgoQY29udHJhY3RfdmVyc2lvbhgCIAEoCUIIukgFcgMYgAEihQEKG1NvbHV0aW9uQmFja2VuZFJlZ2lzdHJhdGlvbhIfCgh1cHN0cmVhbRgBIAEoCUINukgKcggQARiAEIgBARIhCg1zZXJ2aWNlX2FsaWFzGAIgASgJQgq6SAdyBRABGIABEiIKEGNvbnRyYWN0X3ZlcnNpb24YAyABKAlCCLpIBXIDGIABIvsCCh5QdXRTb2x1dGlvblJlZ2lzdHJhdGlvblJlcXVlc3QSQwoLc29sdXRpb25faWQYASABKAlCLrpIK3IpEAEYgAEyIl5bYS16MC05XSg/OlthLXowLTlfLV0qW2EtejAtOV0pPyQSHQoJcHVibGlzaGVyGAIgASgJQgq6SAdyBRABGIACEicKEWV4cGVjdGVkX3JldmlzaW9uGAMgASgDQge6SAQiAiAASAGIAQESIQoNbGVhc2Vfc2Vjb25kcxgEIAEoDUIKukgHKgUYkBwoHhJCCghmcm9udGVuZBgFIAEoCzIuLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25Gcm9udGVuZFJlZ2lzdHJhdGlvbkgAEkAKB2JhY2tlbmQYBiABKAsyLS5zYWFzLmFjY291bnRzLnYxLlNvbHV0aW9uQmFja2VuZFJlZ2lzdHJhdGlvbkgAQg0KBGhhbGYSBbpIAggBQhQKEl9leHBlY3RlZF9yZXZpc2lvbiKDAQohRGVsZXRlU29sdXRpb25SZWdpc3RyYXRpb25SZXF1ZXN0Eh8KC3NvbHV0aW9uX2lkGAEgASgJQgq6SAdyBRABGIABEicKEWV4cGVjdGVkX3JldmlzaW9uGAIgASgDQge6SAQiAiAASACIAQFCFAoSX2V4cGVjdGVkX3JldmlzaW9uIj4KIExpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnNSZXF1ZXN0EhoKEmluY2x1ZGVfdG9tYnN0b25lZBgBIAEoCCJ9CiFMaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVzcG9uc2USPQoNcmVnaXN0cmF0aW9ucxgBIAMoCzImLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25SZWdpc3RyYXRpb24SGQoRcmVnaXN0cnlfcmV2aXNpb24YAiABKAMqowIKGlNvbHV0aW9uUmVnaXN0cmF0aW9uU3RhdHVzEiwKKFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABInCiNTT0xVVElPTl9SRUdJU1RSQVRJT05fU1RBVFVTX0FDVElWRRABEigKJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfUEVORElORxACEigKJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfRVhQSVJFRBADEi0KKVNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfSU5DT01QQVRJQkxFEAQSKwonU09MVVRJT05fUkVHSVNUUkFUSU9OX1NUQVRVU19UT01CU1RPTkVEEAVCvQEKFGNvbS5zYWFzLmFjY291bnRzLnYxQhVTb2x1dGlvblJlZ2lzdHJ5UHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_protobuf_timestamp]); + fileDesc("CihzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5LnByb3RvEhBzYWFzLmFjY291bnRzLnYxIm8KF1NvbHV0aW9uRnJvbnRlbmRCaW5kaW5nEhAKCHJldmlzaW9uGAEgASgDEhAKCG1hbmlmZXN0GAIgASgJEhgKEGNvbnRyYWN0X3ZlcnNpb24YAyABKAlKBAgEEAVSEGxlYXNlX2V4cGlyZXNfYXQihQEKFlNvbHV0aW9uQmFja2VuZEJpbmRpbmcSEAoIcmV2aXNpb24YASABKAMSEAoIdXBzdHJlYW0YAiABKAkSFQoNc2VydmljZV9hbGlhcxgDIAEoCRIYChBjb250cmFjdF92ZXJzaW9uGAQgASgJSgQIBRAGUhBsZWFzZV9leHBpcmVzX2F0IooEChRTb2x1dGlvblJlZ2lzdHJhdGlvbhITCgtzb2x1dGlvbl9pZBgBIAEoCRIRCglwdWJsaXNoZXIYAiABKAkSEAoIcmV2aXNpb24YAyABKAMSPAoGc3RhdHVzGAQgASgOMiwuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvblJlZ2lzdHJhdGlvblN0YXR1cxJACghmcm9udGVuZBgFIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25Gcm9udGVuZEJpbmRpbmdIAIgBARI+CgdiYWNrZW5kGAYgASgLMiguc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkJhY2tlbmRCaW5kaW5nSAGIAQESLgoKdXBkYXRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNgoNdG9tYnN0b25lZF9hdBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAogBARJACghkZWNsYXJlZBgKIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25EZWNsYXJlZEJpbmRpbmdIA4gBAUILCglfZnJvbnRlbmRCCgoIX2JhY2tlbmRCEAoOX3RvbWJzdG9uZWRfYXRCCwoJX2RlY2xhcmVkSgQICRAKUhBydW50aW1lX2JvdW5kYXJ5Ij4KIExpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnNSZXF1ZXN0EhoKEmluY2x1ZGVfdG9tYnN0b25lZBgBIAEoCCJ9CiFMaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVzcG9uc2USPQoNcmVnaXN0cmF0aW9ucxgBIAMoCzImLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25SZWdpc3RyYXRpb24SGQoRcmVnaXN0cnlfcmV2aXNpb24YAiABKAMimwEKF1NvbHV0aW9uRGVjbGFyZWRCaW5kaW5nEhIKCmJpbmRpbmdfaWQYASABKAkSEgoKZ2VuZXJhdGlvbhgCIAEoBBIPCgdyZWxlYXNlGAMgASgJEhEKCXRhcmdldF9pZBgEIAEoCRI0CgRraW5kGAUgASgOMiYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkRlY2xhcmVkS2luZCJ9Ch1Tb2x1dGlvbkhvc3RCaW5kaW5nR2VuZXJhdGlvbhISCgpnZW5lcmF0aW9uGAEgASgEEg4KBmRpZ2VzdBgCIAEoCRIQCghkb2N1bWVudBgDIAEoCRImCgJhdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAisgEKJFNvbHV0aW9uSG9zdEJpbmRpbmdBcHBsaWVkR2VuZXJhdGlvbhJDCgpnZW5lcmF0aW9uGAEgASgLMi8uc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkhvc3RCaW5kaW5nR2VuZXJhdGlvbhIPCgdyZW1vdmVkGAIgASgIEg4KBnJvdXRlcxgDIAMoCRITCgtzb2x1dGlvbl9pZBgEIAEoCRIPCgdyZWxlYXNlGAUgASgJIo4EChhTb2x1dGlvbkhvc3RCaW5kaW5nU3RhdGUSEgoKYmluZGluZ19pZBgBIAEoCRIXCg9ob3N0X2Nvb3JkaW5hdGUYAiABKAkSFgoOaG9zdF9jb21wb25lbnQYAyABKAkSRQoHZGVzaXJlZBgEIAEoCzIvLnNhYXMuYWNjb3VudHMudjEuU29sdXRpb25Ib3N0QmluZGluZ0dlbmVyYXRpb25IAIgBARJMCgdhcHBsaWVkGAUgASgLMjYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkhvc3RCaW5kaW5nQXBwbGllZEdlbmVyYXRpb25IAYgBARIaChJwZW5kaW5nX2dlbmVyYXRpb24YBiABKAQSFgoOcGVuZGluZ19yZWFzb24YByABKAkSNgoNcGVuZGluZ19zaW5jZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAogBARJBCgxyZWdpc3RyYXRpb24YCSABKAsyJi5zYWFzLmFjY291bnRzLnYxLlNvbHV0aW9uUmVnaXN0cmF0aW9uSAOIAQESLgoKdXBkYXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCCgoIX2Rlc2lyZWRCCgoIX2FwcGxpZWRCEAoOX3BlbmRpbmdfc2luY2VCDwoNX3JlZ2lzdHJhdGlvbiIhCh9MaXN0U29sdXRpb25Ib3N0QmluZGluZ3NSZXF1ZXN0ImAKIExpc3RTb2x1dGlvbkhvc3RCaW5kaW5nc1Jlc3BvbnNlEjwKCGJpbmRpbmdzGAEgAygLMiouc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkhvc3RCaW5kaW5nU3RhdGUqpQIKGlNvbHV0aW9uUmVnaXN0cmF0aW9uU3RhdHVzEiwKKFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfVU5TUEVDSUZJRUQQABInCiNTT0xVVElPTl9SRUdJU1RSQVRJT05fU1RBVFVTX0FDVElWRRABEigKJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfUEVORElORxACEi0KKVNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfSU5DT01QQVRJQkxFEAQSKwonU09MVVRJT05fUkVHSVNUUkFUSU9OX1NUQVRVU19UT01CU1RPTkVEEAUiBAgDEAMqJFNPTFVUSU9OX1JFR0lTVFJBVElPTl9TVEFUVVNfRVhQSVJFRCqGAQoUU29sdXRpb25EZWNsYXJlZEtpbmQSJgoiU09MVVRJT05fREVDTEFSRURfS0lORF9VTlNQRUNJRklFRBAAEiMKH1NPTFVUSU9OX0RFQ0xBUkVEX0tJTkRfU09MVVRJT04QARIhCh1TT0xVVElPTl9ERUNMQVJFRF9LSU5EX01PRFVMRRACQr0BChRjb20uc2Fhcy5hY2NvdW50cy52MUIVU29sdXRpb25SZWdpc3RyeVByb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_google_protobuf_timestamp]); /** * SolutionFrontendBinding is the stored frontend half. manifest is the document - * the frontend validated before handing it over; this service persists it - * verbatim and never reinterprets it, so the host keeps sole ownership of what - * a manifest may contain. + * the frontend validates while reading the projection; accounts stores it + * verbatim and keeps presence authority separate from runtime compatibility. * * @generated from message saas.accounts.v1.SolutionFrontendBinding */ @@ -38,11 +36,6 @@ export type SolutionFrontendBinding = Message<"saas.accounts.v1.SolutionFrontend * @generated from field: string contract_version = 3; */ contractVersion: string; - - /** - * @generated from field: google.protobuf.Timestamp lease_expires_at = 4; - */ - leaseExpiresAt?: Timestamp; }; /** @@ -78,11 +71,6 @@ export type SolutionBackendBinding = Message<"saas.accounts.v1.SolutionBackendBi * @generated from field: string contract_version = 4; */ contractVersion: string; - - /** - * @generated from field: google.protobuf.Timestamp lease_expires_at = 5; - */ - leaseExpiresAt?: Timestamp; }; /** @@ -106,8 +94,7 @@ export type SolutionRegistration = Message<"saas.accounts.v1.SolutionRegistratio solutionId: string; /** - * Owner of record. First claim binds it; a registration naming a different - * publisher is refused rather than overwriting. + * Canonical publisher attribution of the declared record. * * @generated from field: string publisher = 2; */ @@ -144,21 +131,11 @@ export type SolutionRegistration = Message<"saas.accounts.v1.SolutionRegistratio tombstonedAt?: Timestamp; /** - * The seed a solution's runtime boundary is derived from (issue #1015). + * The binding and immutable target that declared this record. * - * ALWAYS EMPTY on the wire, on every response, deliberately. The seed is - * assigned by this service when the record is created and never again, and it - * is the only input to the per-organization boundary every Work Context - * minted for that solution is sealed under — so it is what stands between one - * solution's runs and another's. No registrant needs it: accounts derives and - * seals the boundary from the credential the solution already presents. - * - * The field is kept rather than removed so this promise is testable: a change - * that starts populating it fails a test instead of shipping. - * - * @generated from field: string runtime_boundary = 9; + * @generated from field: optional saas.accounts.v1.SolutionDeclaredBinding declared = 10; */ - runtimeBoundary: string; + declared?: SolutionDeclaredBinding; }; /** @@ -169,207 +146,323 @@ export const SolutionRegistrationSchema: GenMessage = /*@_ messageDesc(file_saas_accounts_v1_solution_registry, 2); /** - * SolutionFrontendRegistration is the caller-supplied frontend half. Revision - * and lease are assigned by the server, never proposed by the registrant. + * ListSolutionRegistrationsRequest reads the whole registry. There is one + * registry per deployment and it holds tens of records at most, so consumers + * rebuild their cache from a full snapshot rather than tailing a change feed. * - * @generated from message saas.accounts.v1.SolutionFrontendRegistration + * @generated from message saas.accounts.v1.ListSolutionRegistrationsRequest */ -export type SolutionFrontendRegistration = Message<"saas.accounts.v1.SolutionFrontendRegistration"> & { - /** - * @generated from field: string manifest = 1; - */ - manifest: string; - +export type ListSolutionRegistrationsRequest = Message<"saas.accounts.v1.ListSolutionRegistrationsRequest"> & { /** - * @generated from field: string contract_version = 2; + * Tombstones are omitted by default: a routing cache wants the live set. An + * operator diagnosing "was this deleted or never registered?" asks for them. + * + * @generated from field: bool include_tombstoned = 1; */ - contractVersion: string; + includeTombstoned: boolean; }; /** - * Describes the message saas.accounts.v1.SolutionFrontendRegistration. - * Use `create(SolutionFrontendRegistrationSchema)` to create a new message. + * Describes the message saas.accounts.v1.ListSolutionRegistrationsRequest. + * Use `create(ListSolutionRegistrationsRequestSchema)` to create a new message. */ -export const SolutionFrontendRegistrationSchema: GenMessage = /*@__PURE__*/ +export const ListSolutionRegistrationsRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 3); /** - * SolutionBackendRegistration is the caller-supplied backend half. The gateway - * has already constrained the upstream to a permitted host before it gets here; - * this service stores what it is told and does not re-derive routing policy. - * - * @generated from message saas.accounts.v1.SolutionBackendRegistration + * @generated from message saas.accounts.v1.ListSolutionRegistrationsResponse */ -export type SolutionBackendRegistration = Message<"saas.accounts.v1.SolutionBackendRegistration"> & { - /** - * @generated from field: string upstream = 1; - */ - upstream: string; - +export type ListSolutionRegistrationsResponse = Message<"saas.accounts.v1.ListSolutionRegistrationsResponse"> & { /** - * @generated from field: string service_alias = 2; + * @generated from field: repeated saas.accounts.v1.SolutionRegistration registrations = 1; */ - serviceAlias: string; + registrations: SolutionRegistration[]; /** - * @generated from field: string contract_version = 3; + * Highest revision in the registry, tombstones included. Two consumers that + * report the same registry_revision have converged on the same state. + * + * @generated from field: int64 registry_revision = 2; */ - contractVersion: string; + registryRevision: bigint; }; /** - * Describes the message saas.accounts.v1.SolutionBackendRegistration. - * Use `create(SolutionBackendRegistrationSchema)` to create a new message. + * Describes the message saas.accounts.v1.ListSolutionRegistrationsResponse. + * Use `create(ListSolutionRegistrationsResponseSchema)` to create a new message. */ -export const SolutionBackendRegistrationSchema: GenMessage = /*@__PURE__*/ +export const ListSolutionRegistrationsResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 4); /** - * PutSolutionRegistrationRequest writes exactly one half of one record. - * - * Writing a half whose content is byte-identical to what is stored is a lease - * renewal: it refreshes liveness and deliberately does not advance the - * revision, so a heartbeat never looks like a change to a consumer. Writing - * different content to a half that already exists requires expected_revision, - * which must equal the record's current revision — that is how a publisher - * still holding an older view is stopped from overwriting newer state, and how - * two concurrent updates serialize instead of racing. + * SolutionDeclaredBinding identifies the applied declaration and target. * - * @generated from message saas.accounts.v1.PutSolutionRegistrationRequest + * @generated from message saas.accounts.v1.SolutionDeclaredBinding */ -export type PutSolutionRegistrationRequest = Message<"saas.accounts.v1.PutSolutionRegistrationRequest"> & { +export type SolutionDeclaredBinding = Message<"saas.accounts.v1.SolutionDeclaredBinding"> & { /** - * @generated from field: string solution_id = 1; + * Stable ID of the deployment instance that declared this record. It is not + * the solution id: a binding ID identifies one instance and may carry + * characters a path segment may not. + * + * @generated from field: string binding_id = 1; */ - solutionId: string; + bindingId: string; /** - * @generated from field: string publisher = 2; + * The generation of that binding the host applied into this record. + * + * @generated from field: uint64 generation = 2; */ - publisher: string; + generation: bigint; + + /** + * publisher/name@version of the applied generation. + * + * @generated from field: string release = 3; + */ + release: string; /** - * Compare-and-swap guard against the record's current revision. Required to - * change an existing half, and the only way to re-register a tombstoned - * record: the caller must name the tombstone's own revision, which a delayed - * retry from a retired deployment does not hold. + * The immutable solution target this declaration opened — the identity an + * installation names. It is carried on the registration record so a consumer + * that was asked about a ROUTE ALIAS can resolve it to the target that + * currently serves it and compare identities. + * + * That resolution is the whole admission mechanism: a replacement binding + * which claimed a withdrawn alias resolves to its own target, which no + * installation of its predecessor names. A record with no declaration carries + * no target and is therefore admissible to nobody, which is the fail-closed + * answer and the direction of the cutover. * - * @generated from field: optional int64 expected_revision = 3; + * @generated from field: string target_id = 4; */ - expectedRevision?: bigint; + targetId: string; /** - * Requested liveness window for this half. The registrant renews within it. + * What the declaration declared the presence of. It decides which routing + * surface serves this record, so it is carried rather than inferred: a host + * that read the kind off the shape of a record — a publisher prefix, an alias + * convention — would be guessing at exactly the question that separates a + * surface with per-viewer admission from one without. * - * @generated from field: uint32 lease_seconds = 4; - */ - leaseSeconds: number; - - /** - * @generated from oneof saas.accounts.v1.PutSolutionRegistrationRequest.half - */ - half: { - /** - * @generated from field: saas.accounts.v1.SolutionFrontendRegistration frontend = 5; - */ - value: SolutionFrontendRegistration; - case: "frontend"; - } | { - /** - * @generated from field: saas.accounts.v1.SolutionBackendRegistration backend = 6; - */ - value: SolutionBackendRegistration; - case: "backend"; - } | { case: undefined; value?: undefined }; + * @generated from field: saas.accounts.v1.SolutionDeclaredKind kind = 5; + */ + kind: SolutionDeclaredKind; }; /** - * Describes the message saas.accounts.v1.PutSolutionRegistrationRequest. - * Use `create(PutSolutionRegistrationRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionDeclaredBinding. + * Use `create(SolutionDeclaredBindingSchema)` to create a new message. */ -export const PutSolutionRegistrationRequestSchema: GenMessage = /*@__PURE__*/ +export const SolutionDeclaredBindingSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 5); /** - * DeleteSolutionRegistrationRequest tombstones a record: both halves are - * cleared in the same statement that marks it deleted, so a reader that somehow - * ignored the tombstone still has no endpoint to route to. + * SolutionHostBindingGeneration is one generation of one document. The document + * is carried whole, canonically encoded, because "what is this host running" and + * "what has it been asked to run" are both answers an operator needs in full. * - * @generated from message saas.accounts.v1.DeleteSolutionRegistrationRequest + * @generated from message saas.accounts.v1.SolutionHostBindingGeneration */ -export type DeleteSolutionRegistrationRequest = Message<"saas.accounts.v1.DeleteSolutionRegistrationRequest"> & { +export type SolutionHostBindingGeneration = Message<"saas.accounts.v1.SolutionHostBindingGeneration"> & { /** - * @generated from field: string solution_id = 1; + * @generated from field: uint64 generation = 1; */ - solutionId: string; + generation: bigint; + + /** + * The canonical digest of the document, which is how a rewritten generation is + * told from a re-read of the applied one. + * + * @generated from field: string digest = 2; + */ + digest: string; + + /** + * @generated from field: string document = 3; + */ + document: string; /** - * @generated from field: optional int64 expected_revision = 2; + * @generated from field: google.protobuf.Timestamp at = 4; */ - expectedRevision?: bigint; + at?: Timestamp; }; /** - * Describes the message saas.accounts.v1.DeleteSolutionRegistrationRequest. - * Use `create(DeleteSolutionRegistrationRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionHostBindingGeneration. + * Use `create(SolutionHostBindingGenerationSchema)` to create a new message. */ -export const DeleteSolutionRegistrationRequestSchema: GenMessage = /*@__PURE__*/ +export const SolutionHostBindingGenerationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 6); /** - * ListSolutionRegistrationsRequest reads the whole registry. There is one - * registry per deployment and it holds tens of records at most, so consumers - * rebuild their cache from a full snapshot rather than tailing a change feed. + * SolutionHostBindingAppliedGeneration is the generation this host reconciled, + * and what it reconciled it into. * - * @generated from message saas.accounts.v1.ListSolutionRegistrationsRequest + * @generated from message saas.accounts.v1.SolutionHostBindingAppliedGeneration */ -export type ListSolutionRegistrationsRequest = Message<"saas.accounts.v1.ListSolutionRegistrationsRequest"> & { +export type SolutionHostBindingAppliedGeneration = Message<"saas.accounts.v1.SolutionHostBindingAppliedGeneration"> & { /** - * Tombstones are omitted by default: a routing cache wants the live set. An - * operator diagnosing "was this deleted or never registered?" asks for them. + * @generated from field: saas.accounts.v1.SolutionHostBindingGeneration generation = 1; + */ + generation?: SolutionHostBindingGeneration; + + /** + * A tombstone generation: the binding is declared absent. It is not an + * absence — the generation stays on the record, so a late or replayed older + * generation is still refused after a removal. * - * @generated from field: bool include_tombstoned = 1; + * @generated from field: bool removed = 2; */ - includeTombstoned: boolean; + removed: boolean; + + /** + * The route aliases the applied generation holds. A tombstone holds none. + * + * @generated from field: repeated string routes = 3; + */ + routes: string[]; + + /** + * The registration record this generation reconciled into. A tombstone keeps + * the key it withdrew, which is the only way it knows what to withdraw. + * + * @generated from field: string solution_id = 4; + */ + solutionId: string; + + /** + * @generated from field: string release = 5; + */ + release: string; }; /** - * Describes the message saas.accounts.v1.ListSolutionRegistrationsRequest. - * Use `create(ListSolutionRegistrationsRequestSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionHostBindingAppliedGeneration. + * Use `create(SolutionHostBindingAppliedGenerationSchema)` to create a new message. */ -export const ListSolutionRegistrationsRequestSchema: GenMessage = /*@__PURE__*/ +export const SolutionHostBindingAppliedGenerationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 7); /** - * @generated from message saas.accounts.v1.ListSolutionRegistrationsResponse + * SolutionHostBindingState is one binding's whole state: desired, applied, and + * why they differ. Endpoint and manifest observations live on + * SolutionRegistration, separate from the declaration that grants presence. + * + * @generated from message saas.accounts.v1.SolutionHostBindingState */ -export type ListSolutionRegistrationsResponse = Message<"saas.accounts.v1.ListSolutionRegistrationsResponse"> & { +export type SolutionHostBindingState = Message<"saas.accounts.v1.SolutionHostBindingState"> & { /** - * @generated from field: repeated saas.accounts.v1.SolutionRegistration registrations = 1; + * @generated from field: string binding_id = 1; */ - registrations: SolutionRegistration[]; + bindingId: string; /** - * Highest revision in the registry, tombstones included. Two consumers that - * report the same registry_revision have converged on the same state. + * @generated from field: string host_coordinate = 2; + */ + hostCoordinate: string; + + /** + * @generated from field: string host_component = 3; + */ + hostComponent: string; + + /** + * The newest generation delivery has shown this host, whether or not it + * passed. * - * @generated from field: int64 registry_revision = 2; + * @generated from field: optional saas.accounts.v1.SolutionHostBindingGeneration desired = 4; */ - registryRevision: bigint; + desired?: SolutionHostBindingGeneration; + + /** + * The generation this host applied. Absent means nothing has ever passed, so + * nothing about this binding is being served. + * + * @generated from field: optional saas.accounts.v1.SolutionHostBindingAppliedGeneration applied = 5; + */ + applied?: SolutionHostBindingAppliedGeneration; + + /** + * The generation delivery is showing that this host has not applied, or 0 when + * desired and applied agree. + * + * @generated from field: uint64 pending_generation = 6; + */ + pendingGeneration: bigint; + + /** + * Why the pending generation was not applied. Empty when nothing is pending. + * + * @generated from field: string pending_reason = 7; + */ + pendingReason: string; + + /** + * @generated from field: optional google.protobuf.Timestamp pending_since = 8; + */ + pendingSince?: Timestamp; + + /** + * The registration record this binding declares, when it has applied one. It + * carries endpoint and manifest observations and their derived status. + * + * @generated from field: optional saas.accounts.v1.SolutionRegistration registration = 9; + */ + registration?: SolutionRegistration; + + /** + * @generated from field: google.protobuf.Timestamp updated_at = 10; + */ + updatedAt?: Timestamp; }; /** - * Describes the message saas.accounts.v1.ListSolutionRegistrationsResponse. - * Use `create(ListSolutionRegistrationsResponseSchema)` to create a new message. + * Describes the message saas.accounts.v1.SolutionHostBindingState. + * Use `create(SolutionHostBindingStateSchema)` to create a new message. */ -export const ListSolutionRegistrationsResponseSchema: GenMessage = /*@__PURE__*/ +export const SolutionHostBindingStateSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_solution_registry, 8); /** - * SolutionRegistrationStatus is derived at read time from the stored record; it - * is never a stored column. It is what lets an operator tell a registration - * that was never completed from one whose publisher stopped renewing, and both - * from one that was deliberately removed. + * ListSolutionHostBindingsRequest reads every declared binding. There is one set + * per deployment and it holds tens of bindings at most, so a consumer rebuilds + * its view from a full snapshot. + * + * @generated from message saas.accounts.v1.ListSolutionHostBindingsRequest + */ +export type ListSolutionHostBindingsRequest = Message<"saas.accounts.v1.ListSolutionHostBindingsRequest"> & { +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionHostBindingsRequest. + * Use `create(ListSolutionHostBindingsRequestSchema)` to create a new message. + */ +export const ListSolutionHostBindingsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_registry, 9); + +/** + * @generated from message saas.accounts.v1.ListSolutionHostBindingsResponse + */ +export type ListSolutionHostBindingsResponse = Message<"saas.accounts.v1.ListSolutionHostBindingsResponse"> & { + /** + * @generated from field: repeated saas.accounts.v1.SolutionHostBindingState bindings = 1; + */ + bindings: SolutionHostBindingState[]; +}; + +/** + * Describes the message saas.accounts.v1.ListSolutionHostBindingsResponse. + * Use `create(ListSolutionHostBindingsResponseSchema)` to create a new message. + */ +export const ListSolutionHostBindingsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_solution_registry, 10); + +/** + * SolutionRegistrationStatus is derived from declared state and complete, + * compatible frontend/backend observations, never from an expiring lease. * * @generated from enum saas.accounts.v1.SolutionRegistrationStatus */ @@ -380,7 +473,7 @@ export enum SolutionRegistrationStatus { UNSPECIFIED = 0, /** - * Both halves are present, their leases are live, and their declared contract + * Both halves are present and their declared contract * versions agree. This is the only status either surface serves. * * @generated from enum value: SOLUTION_REGISTRATION_STATUS_ACTIVE = 1; @@ -388,21 +481,13 @@ export enum SolutionRegistrationStatus { ACTIVE = 1, /** - * A half has never registered. The record is durable but incomplete, so no + * A half has not been observed. The record is durable but incomplete, so no * page advertises it and the gateway has nothing to route to. * * @generated from enum value: SOLUTION_REGISTRATION_STATUS_PENDING = 2; */ PENDING = 2, - /** - * Both halves registered, but at least one lease lapsed: the deployment that - * owns it stopped renewing. The intent survives; the endpoint is not live. - * - * @generated from enum value: SOLUTION_REGISTRATION_STATUS_EXPIRED = 3; - */ - EXPIRED = 3, - /** * The two halves declare different contract versions, so activating them * together would serve a page against a backend it was not built for. @@ -412,8 +497,7 @@ export enum SolutionRegistrationStatus { INCOMPATIBLE = 4, /** - * The registration was deregistered. The row survives as a tombstone so a - * delayed heartbeat from a retiring deployment cannot recreate it. + * An applied removal keeps the declaration as a tombstone. * * @generated from enum value: SOLUTION_REGISTRATION_STATUS_TOMBSTONED = 5; */ @@ -426,3 +510,49 @@ export enum SolutionRegistrationStatus { export const SolutionRegistrationStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_saas_accounts_v1_solution_registry, 0); +/** + * SolutionDeclaredKind is what a declaration declares the presence of. + * + * Core requires it on every presence document and refuses any other value, so + * every admitted declaration has exactly one of the two. The host used to drop + * it on reconcile, which left the registry unable to say whether a record was a + * solution or a module — and the two are routed on different surfaces with + * different admission, so a host that cannot tell them apart must either serve + * both from one surface or serve neither. + * + * UNSPECIFIED is not a third kind and is never a default. It is what a reader + * decodes from a writer that did not set the field, and both routing surfaces + * refuse it by name rather than picking the kind that happens to be more + * permissive. + * + * @generated from enum saas.accounts.v1.SolutionDeclaredKind + */ +export enum SolutionDeclaredKind { + /** + * @generated from enum value: SOLUTION_DECLARED_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * A composed solution instance, routed at /solutions//* with + * per-viewer installation admission. + * + * @generated from enum value: SOLUTION_DECLARED_KIND_SOLUTION = 1; + */ + SOLUTION = 1, + + /** + * One module instance, routed at /v1//* with the ordinary + * authenticated pipeline. + * + * @generated from enum value: SOLUTION_DECLARED_KIND_MODULE = 2; + */ + MODULE = 2, +} + +/** + * Describes the enum saas.accounts.v1.SolutionDeclaredKind. + */ +export const SolutionDeclaredKindSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_saas_accounts_v1_solution_registry, 1); + diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_service_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_service_pb.ts index 6a770a377..2635a0ff2 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_service_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_registry_service_pb.ts @@ -4,7 +4,7 @@ import type { GenFile, GenService } from "@bufbuild/protobuf/codegenv2"; import { fileDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { DeleteSolutionRegistrationRequestSchema, ListSolutionRegistrationsRequestSchema, ListSolutionRegistrationsResponseSchema, PutSolutionRegistrationRequestSchema, SolutionRegistrationSchema } from "./solution_registry_pb"; +import type { ListSolutionHostBindingsRequestSchema, ListSolutionHostBindingsResponseSchema, ListSolutionRegistrationsRequestSchema, ListSolutionRegistrationsResponseSchema } from "./solution_registry_pb"; import { file_saas_accounts_v1_solution_registry } from "./solution_registry_pb"; import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; @@ -12,37 +12,28 @@ import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; * Describes the file saas/accounts/v1/solution_registry_service.proto. */ export const file_saas_accounts_v1_solution_registry_service: GenFile = /*@__PURE__*/ - fileDesc("CjBzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5X3NlcnZpY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEyqAQKF1NvbHV0aW9uUmVnaXN0cnlTZXJ2aWNlErEBChdQdXRTb2x1dGlvblJlZ2lzdHJhdGlvbhIwLnNhYXMuYWNjb3VudHMudjEuUHV0U29sdXRpb25SZWdpc3RyYXRpb25SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvblJlZ2lzdHJhdGlvbiI8wvMYOAgDEAEwATomCiJzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl91cGRhdGVkEAJAAUgHUANYA2ABErcBChpEZWxldGVTb2x1dGlvblJlZ2lzdHJhdGlvbhIzLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU29sdXRpb25SZWdpc3RyYXRpb25SZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvblJlZ2lzdHJhdGlvbiI8wvMYOAgDEAEwATomCiJzYWFzLnNvbHV0aW9uLnJlZ2lzdHJhdGlvbl9kZWxldGVkEAJAAUgHUANYA2ABEp4BChlMaXN0U29sdXRpb25SZWdpc3RyYXRpb25zEjIuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVxdWVzdBozLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvbHV0aW9uUmVnaXN0cmF0aW9uc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAFCxAEKFGNvbS5zYWFzLmFjY291bnRzLnYxQhxTb2x1dGlvblJlZ2lzdHJ5U2VydmljZVByb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_saas_accounts_v1_solution_registry, file_saas_policy_v1_options]); + fileDesc("CjBzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX3JlZ2lzdHJ5X3NlcnZpY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEy2AIKF1NvbHV0aW9uUmVnaXN0cnlTZXJ2aWNlEpsBChhMaXN0U29sdXRpb25Ib3N0QmluZGluZ3MSMS5zYWFzLmFjY291bnRzLnYxLkxpc3RTb2x1dGlvbkhvc3RCaW5kaW5nc1JlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLkxpc3RTb2x1dGlvbkhvc3RCaW5kaW5nc1Jlc3BvbnNlIhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESngEKGUxpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnMSMi5zYWFzLmFjY291bnRzLnYxLkxpc3RTb2x1dGlvblJlZ2lzdHJhdGlvbnNSZXF1ZXN0GjMuc2Fhcy5hY2NvdW50cy52MS5MaXN0U29sdXRpb25SZWdpc3RyYXRpb25zUmVzcG9uc2UiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgAULEAQoUY29tLnNhYXMuYWNjb3VudHMudjFCHFNvbHV0aW9uUmVnaXN0cnlTZXJ2aWNlUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [file_saas_accounts_v1_solution_registry, file_saas_policy_v1_options]); /** - * SolutionRegistryService is the durable authority behind both self-registration - * surfaces. Every method is internal: the gateway is the only client, and it - * brokers the frontend's half as well as its own. + * Internal read service for declared solution state. The gateway is the client + * and brokers the registry projection to the frontend. * * @generated from service saas.accounts.v1.SolutionRegistryService */ export const SolutionRegistryService: GenService<{ /** - * PutSolutionRegistration writes or renews one half of a solution's - * registration under compare-and-swap revision semantics. + * ListSolutionHostBindings returns the declared bindings: what delivery has + * shown this host, what the host applied, and why a desired generation is not + * the applied one (issue #952). It is the answer to "is this solution missing, + * or declared and unhealthy?", which neither the registry snapshot nor a health + * probe can give on its own. * - * @generated from rpc saas.accounts.v1.SolutionRegistryService.PutSolutionRegistration + * @generated from rpc saas.accounts.v1.SolutionRegistryService.ListSolutionHostBindings */ - putSolutionRegistration: { + listSolutionHostBindings: { methodKind: "unary"; - input: typeof PutSolutionRegistrationRequestSchema; - output: typeof SolutionRegistrationSchema; - }, - /** - * DeleteSolutionRegistration deregisters a solution, leaving a tombstone that - * a later heartbeat from the retired deployment cannot resurrect. - * - * @generated from rpc saas.accounts.v1.SolutionRegistryService.DeleteSolutionRegistration - */ - deleteSolutionRegistration: { - methodKind: "unary"; - input: typeof DeleteSolutionRegistrationRequestSchema; - output: typeof SolutionRegistrationSchema; + input: typeof ListSolutionHostBindingsRequestSchema; + output: typeof ListSolutionHostBindingsResponseSchema; }, /** * ListSolutionRegistrations returns the whole registry so a restarted or diff --git a/module/services/frontend/code/src/solutions/SolutionsMenu.tsx b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx index a39c354c2..0fba17754 100644 --- a/module/services/frontend/code/src/solutions/SolutionsMenu.tsx +++ b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx @@ -3,9 +3,23 @@ import Link from "next/link"; import { useSyncExternalStore } from "react"; +import { decodeJWTPayload } from "@/lib/auth-session"; +import { + authedFetch, + getToken, + subscribeToken, +} from "@/lib/connect/token-store"; + interface SolutionNav { id: string; nav: { title: string; path: string; order?: number }; + /** + * False when the solution is installed and granted but its installation is + * not healthy. It stays in the menu, disabled: the organization did install it + * and this viewer was granted it, so hiding it would send someone looking for + * a grant that already exists. + */ + available?: boolean; } // Single shared poll loop for the registered-solutions list. Every mounted @@ -18,6 +32,62 @@ const EMPTY: SolutionNav[] = []; let snapshot: SolutionNav[] = EMPTY; const listeners = new Set(); let timer: ReturnType | null = null; +let stopWatchingToken: (() => void) | null = null; + +// Whose list `snapshot` is. The list is per viewer and per organization (#949), +// and it lives in this module, not in a component — so it outlives an unmount, a +// sign-out and the next person signing in on the same tab. Without an owner, the +// next viewer is shown the previous viewer's solutions until their own first +// poll succeeds, and indefinitely if it never does. +let snapshotViewer: string | null = null; + +/** + * The viewer the current token speaks for: the effective subject (the + * impersonated user during an impersonation, as the server narrows on) in its + * organization, or null when signed out. + * + * This reads the token's claims without verifying them, which is deliberate and + * safe HERE: the answer only decides whether to throw away a client-side copy of a + * list, never what anyone may see — the server narrows on the identity the gateway + * verified. It is the same presentational use lib/auth-session.ts's + * resolveSessionUser makes of the claims. + */ +function currentViewer(): string | null { + const token = getToken(); + if (!token) return null; + const claims = decodeJWTPayload(token); + const subject = + typeof claims.acting === "string" && claims.acting + ? claims.acting + : typeof claims.sub === "string" + ? claims.sub + : ""; + const org = typeof claims.org === "string" ? claims.org : ""; + return `${subject}\u0000${org}`; +} + +function publish(next: SolutionNav[]): void { + // Keep the reference stable when nothing changed so subscribers don't + // re-render on every poll (useSyncExternalStore compares by identity). + if (sameList(snapshot, next)) return; + snapshot = next; + for (const listener of listeners) { + listener(); + } +} + +/** + * Drop the list when it belongs to someone else. A routine token refresh keeps the + * same viewer, so it changes nothing and the menu does not flicker; a sign-out, a + * different person, or a switch of organization empties it at once. + */ +function forgetIfViewerChanged(): boolean { + const viewer = currentViewer(); + if (viewer === snapshotViewer) return false; + snapshotViewer = viewer; + publish(EMPTY); + return true; +} function sameList(a: SolutionNav[], b: SolutionNav[]): boolean { if (a === b) return true; @@ -27,33 +97,48 @@ function sameList(a: SolutionNav[], b: SolutionNav[]): boolean { return ( item.id === other.id && item.nav.title === other.nav.title && - item.nav.path === other.nav.path + item.nav.path === other.nav.path && + // Availability is rendered, so a change in it must re-render. Leaving it + // out of the comparison would freeze a solution's disabled state for the + // life of the page. + item.available === other.available ); }); } async function refresh(): Promise { + forgetIfViewerChanged(); + const viewer = snapshotViewer; + if (viewer === null) return; try { - const response = await fetch("/api/solutions/register", { + // The menu is this viewer's own (#949), so the listing is authenticated and + // answers what THIS viewer may use. authedFetch attaches the host's bearer + // and, on a lapsed access token, exchanges it once and retries — the same + // recovery every other authenticated call gets. A bare fetch would 401 on + // every poll after the access token aged out and empty a working menu. + const response = await authedFetch("/api/solutions", { cache: "no-store", }); + // Answered for a viewer who is no longer here: a poll started before a + // sign-out or a switch must not paint its list over the next viewer's. + if (currentViewer() !== viewer) return; + if (response.status === 401) { + // authedFetch already tried to recover the session and could not, so + // this viewer is no longer signed in. Their list is not kept on screen. + publish(EMPTY); + return; + } if (!response.ok) { - // The listing answers 503 when this replica cannot read the registry. - // Treating that as "no solutions" would empty a working nav on a - // blip: an unreadable registry is not an empty one, which is the - // whole reason the route distinguishes them. + // Every other non-OK answer keeps this viewer's last known list. 503 is + // an unreadable registry or an authority that could not answer; 429 is + // the organization's read budget. Treating any of them as "no solutions" + // would empty a working nav on a blip — the reason the route + // distinguishes them from an empty projection at all. return; } const data: { solutions?: SolutionNav[] } = await response.json(); - const next = data.solutions ?? []; - // Keep the reference stable when nothing changed so subscribers don't - // re-render on every poll (useSyncExternalStore compares by identity). - if (!sameList(snapshot, next)) { - snapshot = next; - for (const listener of listeners) { - listener(); - } - } + if (currentViewer() !== viewer) return; + publish(data.solutions ?? []); } catch { // Network blip — keep the last known list. } @@ -64,18 +149,31 @@ function subscribe(listener: Listener): () => void { if (listeners.size === 1) { void refresh(); timer = setInterval(refresh, 10_000); + // Re-check ownership the moment the token changes rather than on the next + // tick, so a new viewer never waits up to one poll with someone else's list. + stopWatchingToken = subscribeToken(() => { + if (forgetIfViewerChanged()) void refresh(); + }); } return () => { listeners.delete(listener); - if (listeners.size === 0 && timer) { - clearInterval(timer); - timer = null; + if (listeners.size === 0) { + if (timer) { + clearInterval(timer); + timer = null; + } + stopWatchingToken?.(); + stopWatchingToken = null; } }; } function getSnapshot(): SolutionNav[] { - return snapshot; + // React reads this during render, before subscribe has run: on a remount after + // a sign-out or a switch, returning `snapshot` as-is would paint the previous + // viewer's list for that render. EMPTY is a constant, so the answer stays + // stable for useSyncExternalStore until refresh publishes this viewer's own. + return currentViewer() === snapshotViewer ? snapshot : EMPTY; } /** @@ -96,30 +194,56 @@ export function SolutionsMenu({ variant = "list" }: { variant?: "list" | "cards" if (variant === "cards") { return (
- {solutions.map((solution) => ( - -
{solution.nav.title}
-
{solution.id}
- - ))} + {solutions.map((solution) => + solution.available === false ? ( + // Rendered, not linked. The grant exists, so the entry belongs + // here; the installation does not currently serve, so following + // it would fail after a navigation rather than before one. +
+
{solution.nav.title}
+
Unavailable
+
+ ) : ( + +
{solution.nav.title}
+
{solution.id}
+ + ), + )}
); } return ( ); } diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx new file mode 100644 index 000000000..0ce0afd97 --- /dev/null +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx @@ -0,0 +1,158 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The menu's store is module state, so each test loads a fresh copy of the module +// with its own token and fetch stubs. +const tokenState = vi.hoisted(() => ({ + token: null as string | null, + fetch: vi.fn<(input: string) => Promise>(), +})); + +vi.mock("@/lib/connect/token-store", () => ({ + getToken: () => tokenState.token, + authedFetch: (input: string) => tokenState.fetch(input), + subscribeToken: (listener: () => void) => { + window.addEventListener("codefly:auth-changed", listener); + return () => window.removeEventListener("codefly:auth-changed", listener); + }, +})); + +function token(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims), "utf8").toString( + "base64url", + ); + return `header.${payload}.signature`; +} + +function signIn(claims: Record) { + tokenState.token = token(claims); + window.dispatchEvent(new Event("codefly:auth-changed")); +} + +function listing(...ids: string[]): Response { + return Response.json({ + solutions: ids.map((id) => ({ + id, + nav: { title: id.toUpperCase(), path: `/s/${id}` }, + available: true, + })), + }); +} + +async function mountMenu() { + const { SolutionsMenu } = await import("@/solutions/SolutionsMenu"); + return render(); +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +const ALICE = { sub: "user-alice", org: "org-acme" }; +const BOB = { sub: "user-bob", org: "org-acme" }; + +describe("SolutionsMenu ownership (#949)", () => { + beforeEach(() => { + vi.resetModules(); + tokenState.token = token(ALICE); + tokenState.fetch.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("does not show the previous viewer's list to the next one", async () => { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await settle(); + expect(screen.queryByText("AUDIT")).not.toBeNull(); + + // Bob signs in on the same tab and his first poll cannot be answered. + // Keeping "the last known list" would keep ALICE's. + tokenState.fetch.mockResolvedValue( + Response.json({ error: "authority_unavailable" }, { status: 503 }), + ); + await act(async () => signIn(BOB)); + await settle(); + expect(screen.queryByText("AUDIT")).toBeNull(); + }); + + it("discards a response that was started for a viewer who has since left", async () => { + let answerAlice: (response: Response) => void = () => {}; + tokenState.fetch.mockReturnValueOnce( + new Promise((resolve) => { + answerAlice = resolve; + }), + ); + await mountMenu(); + + tokenState.fetch.mockResolvedValue(listing("ledger")); + await act(async () => signIn(BOB)); + await settle(); + // Alice's slow answer lands after the switch; it must not paint over Bob's. + await act(async () => answerAlice(listing("audit"))); + await settle(); + expect(screen.queryByText("AUDIT")).toBeNull(); + expect(screen.queryByText("LEDGER")).not.toBeNull(); + }); + + it("clears the list on a 401 the transport could not recover", async () => { + vi.useFakeTimers(); + try { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.queryByText("AUDIT")).not.toBeNull(); + + // authedFetch already tried to exchange the session and could not, so + // the viewer is signed out. Their list must not stay on screen. + tokenState.fetch.mockResolvedValue( + Response.json({ error: "unauthenticated" }, { status: 401 }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(tokenState.fetch).toHaveBeenCalledTimes(2); + expect(screen.queryByText("AUDIT")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the list through a 503 for the same viewer", async () => { + vi.useFakeTimers(); + try { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + tokenState.fetch.mockResolvedValue( + Response.json({ error: "authority_unavailable" }, { status: 503 }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + // An outage is not a sign-out: this viewer keeps their own list. + expect(screen.queryByText("AUDIT")).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the list across a routine token refresh for the same viewer", async () => { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await settle(); + + await act(async () => signIn({ ...ALICE, jti: "rotated" })); + await settle(); + expect(screen.queryByText("AUDIT")).not.toBeNull(); + expect(tokenState.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/registration-cutover.test.ts b/module/services/frontend/code/src/solutions/__tests__/registration-cutover.test.ts new file mode 100644 index 000000000..6da1944c3 --- /dev/null +++ b/module/services/frontend/code/src/solutions/__tests__/registration-cutover.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { file_saas_accounts_v1_solution_registry as hostRegistry } from "@/gen/saas/accounts/v1/solution_registry_pb"; +import { SolutionRegistryService as hostService } from "@/gen/saas/accounts/v1/solution_registry_service_pb"; +import { file_saas_accounts_v1_solution_registry as sdkRegistry } from "../../../packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_pb"; +import { SolutionRegistryService as sdkService } from "../../../packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_registry_service_pb"; + +describe.each([ + ["host", hostRegistry, hostService], + ["SDK", sdkRegistry, sdkService], +] as const)("%s registry wire contract", (_name, registry, service) => { + it("exposes only reads and no runtime registration messages or lease fields", () => { + expect(service.methods.map((method) => method.name)).toEqual([ + "ListSolutionHostBindings", "ListSolutionRegistrations", + ]); + for (const name of ["SolutionFrontendBinding", "SolutionBackendBinding"]) { + const message = registry.messages.find((message) => message.name === name); + expect(message).toBeDefined(); + expect(message!.fields.map((field) => field.name)).not.toContain("lease_expires_at"); + } + for (const name of ["PutSolutionRegistrationRequest", "DeleteSolutionRegistrationRequest"]) { + expect(registry.messages.map((message) => message.name)).not.toContain(name); + } + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/registration-log.test.ts b/module/services/frontend/code/src/solutions/__tests__/registration-log.test.ts deleted file mode 100644 index d1b09bb4d..000000000 --- a/module/services/frontend/code/src/solutions/__tests__/registration-log.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - observeAuthorityReachable, - observeAuthorityRefusal, - observeRegistrationBeat, - observeRegistrationRemoved, -} from "@/solutions/registration-log"; - -const registered = (revision = 7, status = "active") => - ({ ok: true, revision, status }) as const; -const refused = ( - httpStatus: number, - reason: "invalid_manifest" | "incompatible_runtime" | "registry unavailable", - detail?: string, -) => ({ ok: false, httpStatus, reason, detail }) as const; - -describe("registration heartbeat log", () => { - let info: ReturnType; - let warn: ReturnType; - - beforeEach(() => { - const scope = globalThis as Record; - scope.__solutionRegistrationLog = undefined; - scope.__solutionRegistrationAuthority = undefined; - info = vi.spyOn(console, "info").mockImplementation(() => {}); - warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("logs a registration once and never the beats that renew it", () => { - expect(observeRegistrationBeat("example", registered())).toContain( - '"example" registered (revision 7, active)', - ); - for (let beat = 0; beat < 100; beat++) { - expect(observeRegistrationBeat("example", registered())).toBeUndefined(); - } - expect(info).toHaveBeenCalledTimes(1); - expect(warn).not.toHaveBeenCalled(); - }); - - it("logs a new revision with the beats the previous one held", () => { - observeRegistrationBeat("example", registered(7)); - observeRegistrationBeat("example", registered(7)); - observeRegistrationBeat("example", registered(7)); - expect(observeRegistrationBeat("example", registered(9))).toContain( - "now at revision 9 (active; was revision 7, active, held after 3 beats)", - ); - expect(info).toHaveBeenCalledTimes(2); - }); - - it("logs a refusal once, a different refusal again, and the recovery", () => { - observeRegistrationBeat("example", registered(7)); - expect( - observeRegistrationBeat("example", refused(503, "registry unavailable")), - ).toContain( - '"example" refused 503 registry unavailable — was registered at revision 7 after 1 beat', - ); - for (let beat = 0; beat < 8; beat++) { - observeRegistrationBeat("example", refused(503, "registry unavailable")); - } - expect(warn).toHaveBeenCalledTimes(1); - expect( - observeRegistrationBeat("example", refused(409, "incompatible_runtime")), - ).toContain("was refused 503 registry unavailable after 9 beats"); - expect(warn).toHaveBeenCalledTimes(2); - expect(observeRegistrationBeat("example", registered(8))).toContain( - "registered again (revision 8, active) — recovered from 409 incompatible_runtime after 1 beat", - ); - expect(info).toHaveBeenCalledTimes(2); - }); - - // A refusal that prints once and never again is a log that goes quiet while - // the registration is still broken. It says so again at 10, 100, 1000 … - it("says a standing refusal is still standing, at milestones", () => { - for (let beat = 0; beat < 100; beat++) { - observeRegistrationBeat("example", refused(503, "registry unavailable")); - } - expect(warn).toHaveBeenCalledTimes(3); - expect(String(warn.mock.calls[1]?.[0])).toContain( - "is still refused 503 registry unavailable after 10 beats", - ); - expect(String(warn.mock.calls[2]?.[0])).toContain( - "is still refused 503 registry unavailable after 100 beats", - ); - }); - - // The reason is the identity of the refusal; the detail is not. A registrant - // that varies one field per beat must not get a line per beat. - it("does not re-log a refusal whose detail changes but whose reason does not", () => { - observeRegistrationBeat( - "example", - refused(409, "incompatible_runtime", "host React 19 does not satisfy 17"), - ); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain( - "host React 19 does not satisfy 17", - ); - for (let beat = 0; beat < 8; beat++) { - expect( - observeRegistrationBeat( - "example", - refused(409, "incompatible_runtime", `attempt ${beat}`), - ), - ).toBeUndefined(); - } - expect(warn).toHaveBeenCalledTimes(1); - }); - - // A log record's boundary is this module's to hold: a newline in a value - // taken from the request body writes a second line that reads like a real - // one. - it("escapes control characters in every value it interpolates", () => { - observeRegistrationBeat( - "example", - refused( - 403, - "invalid_manifest", - 'manifest names "x\nsolution registration: "audit" registered (revision 999, active)"', - ), - ); - const line = String(warn.mock.calls[0]?.[0]); - expect(line).not.toContain("\n"); - expect(line).toContain("\\x0a"); - }); - - it("bounds the detail it will print", () => { - observeRegistrationBeat( - "example", - refused(409, "incompatible_runtime", "x".repeat(5000)), - ); - expect(String(warn.mock.calls[0]?.[0]).length).toBeLessThan(500); - }); - - it("keeps each registrant's state apart", () => { - observeRegistrationBeat("example-a", registered(1)); - observeRegistrationBeat("example-b", registered(1)); - observeRegistrationBeat("example-a", registered(1)); - observeRegistrationBeat("example-b", registered(1)); - expect(info).toHaveBeenCalledTimes(2); - }); - - it("logs a removal, and the next beat as a fresh registration", () => { - observeRegistrationBeat("example", registered(7)); - observeRegistrationBeat("example", registered(7)); - expect(observeRegistrationRemoved("example", 8)).toContain( - '"example" removed its registration (revision 8), held after 2 beats', - ); - expect(observeRegistrationBeat("example", registered(9))).toContain( - '"example" registered (revision 9, active)', - ); - }); - - it("holds a bounded number of registrants", () => { - for (let index = 0; index < 1000; index++) { - observeRegistrationBeat(`example-${index}`, registered(1)); - } - const states = (globalThis as Record) - .__solutionRegistrationLog as Map; - expect(states.size).toBeLessThanOrEqual(256); - expect(states.has("example-999")).toBe(true); - expect(states.has("example-0")).toBe(false); - }); - - // A registration that beats steadily without changing is the most recently - // seen of all. Held at the position of its last CHANGE, it is the first - // entry a churning neighbour evicts — and its next beat then logs a fresh - // registration that never happened. - it("does not evict the registrant that is beating most steadily", () => { - observeRegistrationBeat("steady", registered(1)); - for (let index = 0; index < 1000; index++) { - observeRegistrationBeat("steady", registered(1)); - observeRegistrationBeat(`churn-${index}`, registered(index + 2)); - } - const states = (globalThis as Record) - .__solutionRegistrationLog as Map; - expect(states.size).toBeLessThanOrEqual(256); - expect(states.has("steady")).toBe(true); - expect(observeRegistrationBeat("steady", registered(1))).toBeUndefined(); - }); -}); - -// A beat whose credential did not verify belongs to no registrant. Held under a -// stand-in one it could never recover — nothing unverified ever succeeds — so -// the key set coming back was never reported, and a refused credential and an -// unreachable key set at the same time evicted each other's state and printed -// on every beat. -describe("registration credential-check log", () => { - let info: ReturnType; - let warn: ReturnType; - - beforeEach(() => { - const scope = globalThis as Record; - scope.__solutionRegistrationLog = undefined; - scope.__solutionRegistrationAuthority = undefined; - info = vi.spyOn(console, "info").mockImplementation(() => {}); - warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("reports the key set coming back, which no beat can say for itself", () => { - expect(observeAuthorityRefusal("unreachable")).toContain( - "the registration key set is unreachable", - ); - for (let beat = 0; beat < 8; beat++) { - expect(observeAuthorityRefusal("unreachable")).toBeUndefined(); - } - expect(warn).toHaveBeenCalledTimes(1); - expect(observeAuthorityReachable()).toContain( - "the registration key set is reachable again; it answered 503 for 9 beats", - ); - expect(info).toHaveBeenCalledTimes(1); - // Nothing left open: a later verified beat is not a second recovery. - expect(observeAuthorityReachable()).toBeUndefined(); - expect(info).toHaveBeenCalledTimes(1); - }); - - it("keeps an unreachable key set and a refused credential apart", () => { - observeAuthorityRefusal("unreachable"); - observeAuthorityRefusal("refused"); - expect(warn).toHaveBeenCalledTimes(2); - for (let beat = 0; beat < 8; beat++) { - expect(observeAuthorityRefusal("unreachable")).toBeUndefined(); - expect(observeAuthorityRefusal("refused")).toBeUndefined(); - } - expect(warn).toHaveBeenCalledTimes(2); - }); - - // Someone else's beat verifying says nothing about the caller whose - // credential this host rejected. - it("does not clear a refused credential when another beat verifies", () => { - observeAuthorityRefusal("refused"); - expect(observeAuthorityReachable()).toBeUndefined(); - for (let beat = 0; beat < 8; beat++) { - observeAuthorityRefusal("refused"); - } - expect(warn).toHaveBeenCalledTimes(1); - expect(observeAuthorityRefusal("refused")).toContain("after 10 beats"); - }); - - it("says a standing key set outage is still standing, at milestones", () => { - for (let beat = 0; beat < 100; beat++) { - observeAuthorityRefusal("unreachable"); - } - expect(warn).toHaveBeenCalledTimes(3); - expect(String(warn.mock.calls[2]?.[0])).toContain("after 100 beats"); - }); -}); diff --git a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts index 4e3ac98e0..d72e4d9ea 100644 --- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts @@ -13,17 +13,40 @@ const { getEndpoints, getWorkspaceSecret } = vi.hoisted(() => ({ })); vi.mock("codefly", () => ({ getEndpoints, getWorkspaceSecret })); +import type { + SolutionEntitlement, + ViewerEntitlements, +} from "@/solutions/entitlements"; +import { + cachedProjection, + entitledSolutions, + invalidateProjections, + navProjection, + surfacesProjection, +} from "@/solutions/projections"; import { browserManifestUrl, detailProjection, findSolution, loadSolutions, parseManifest, - registerSolution, - surfacesProjection, - unregisterSolution, } from "@/solutions/registry"; +// The host's target for a route alias, in these fixtures. A projection joins +// entitlements to manifests on the TARGET, so a fixture has to make the two +// agree the way the registry snapshot does — the manifest's target is stamped +// from the record, never parsed from the solution's own manifest. +const targetFor = (alias: string) => `target-${alias}`; + +// The entitlement every projection now requires. A healthy one is the ordinary +// case: the org installed the solution and the viewer's team was granted it. +const granted: SolutionEntitlement = { + targetId: targetFor("audit"), + healthy: true, + scopeNodeId: "11111111-1111-1111-1111-111111111111", +}; +const grantedButUnhealthy: SolutionEntitlement = { ...granted, healthy: false }; + function baseManifest(overrides: Record = {}) { return { id: "audit", @@ -502,11 +525,12 @@ describe("surfacesProjection", () => { ]); it("projects only the asked-for kind, named by the solution", () => { - expect(surfacesProjection(manifest, "word")).toEqual({ + expect(surfacesProjection(manifest, "word", granted)).toEqual({ id: "audit", title: "Audit", // Without this the declared module path resolves against nothing. origin: "https://audit.internal", + available: true, surfaces: [ { id: "footnote", @@ -523,14 +547,14 @@ describe("surfacesProjection", () => { }); it("carries the origin without the manifest path it came from", () => { - const projected = surfacesProjection(manifest, "word"); + const projected = surfacesProjection(manifest, "word", granted); expect(projected?.origin).toBe("https://audit.internal"); expect(JSON.stringify(projected)).not.toContain("mf-manifest.json"); }); it("reports nothing for a kind the solution does not serve", () => { - expect(surfacesProjection(manifest, "excel")).toBeNull(); - expect(surfacesProjection(withSurfaces([]), "word")).toBeNull(); + expect(surfacesProjection(manifest, "excel", granted)).toBeNull(); + expect(surfacesProjection(withSurfaces([]), "word", granted)).toBeNull(); }); it("does not hand out a reference into the cached snapshot", () => { @@ -549,7 +573,7 @@ describe("surfacesProjection", () => { events: ["documents.entry.*"], }, ]); - const surface = surfacesProjection(cached, "word")?.surfaces[0]; + const surface = surfacesProjection(cached, "word", granted)?.surfaces[0]; if (surface === undefined) throw new Error("expected one surface"); surface.title = "Rewritten"; surface.events?.push("injected"); @@ -585,7 +609,7 @@ describe("registry snapshot", () => { solutions: Array<{ id: string; status: string; manifest?: string }>, ) { return new Response( - JSON.stringify({ revision: 7, leaseSeconds: 120, solutions }), + JSON.stringify({ revision: 7, solutions }), { status: 200, headers: { "content-type": "application/json" }, @@ -610,6 +634,15 @@ describe("registry snapshot", () => { getWorkspaceSecret.mockReset(); }); + it("checks runtime compatibility on the read-only projection", async () => { + const manifest = baseManifest({ schemaVersion: 999 }); + vi.stubGlobal("fetch", vi.fn(async () => snapshotResponse([ + { id: "audit", status: "active", manifest: JSON.stringify(manifest) }, + ]))); + expect(await loadSolutions()).toEqual([]); + expect(await findSolution("audit")).toBeNull(); + }); + // Every browser polling the navigation drives this read, so a registry // outage printed a line per read for as long as it lasted — and the // registration endpoint's own per-request line is switched off for that path @@ -715,87 +748,18 @@ describe("registry snapshot", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - // A registry outage must degrade, not delete: a blip after a good read keeps - // serving the last snapshot instead of emptying the navigation. - it("keeps the last snapshot when a refetch fails", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - snapshotResponse([ - { id: "a", status: "active", manifest: manifestFor("a", 1) }, - ]), - ) - .mockRejectedValue(new Error("unreachable")); - vi.stubGlobal("fetch", fetchMock); - - expect(await loadSolutions()).toHaveLength(1); - (globalThis as Record).__solutionSnapshot = { - ...((globalThis as Record).__solutionSnapshot as object), - expiresAt: 0, - }; - expect(await loadSolutions()).toHaveLength(1); - }); - - // Degrading on a blip is right; degrading forever is not. Past the gateway's - // lease nothing in the held snapshot is provably still registered, so - // continuing to serve it renders pages the gateway has already stopped - // routing. - it("stops serving a stale snapshot once it outlives the gateway lease", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - snapshotResponse([ - { id: "a", status: "active", manifest: manifestFor("a", 1) }, - ]), - ) - .mockRejectedValue(new Error("unreachable")); - vi.stubGlobal("fetch", fetchMock); - + it("drops an expired snapshot when a refetch fails", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(snapshotResponse([ + { id: "a", status: "active", manifest: manifestFor("a", 1) }, + ])) + .mockRejectedValue(new Error("unreachable"))); expect(await loadSolutions()).toHaveLength(1); - - const g = globalThis as Record; - g.__solutionSnapshot = { - ...(g.__solutionSnapshot as object), - expiresAt: 0, - fetchedAt: Date.now() - 120_001, - }; - + const state = globalThis as Record; + state.__solutionSnapshot = { ...(state.__solutionSnapshot as object), expiresAt: 0 }; expect(await loadSolutions()).toBe("unavailable"); expect(await findSolution("a")).toBe("unavailable"); - }); - - // The ceiling must come from the gateway's own lease, not a copy of it: a - // mirrored literal keeps the old bound when the gateway's lease changes. - it("bounds staleness by the lease the gateway reported, not a local copy", async () => { - const shortLease = new Response( - JSON.stringify({ - revision: 7, - leaseSeconds: 10, - solutions: [ - { id: "a", status: "active", manifest: manifestFor("a", 1) }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(shortLease) - .mockRejectedValue(new Error("unreachable")), - ); - - expect(await loadSolutions()).toHaveLength(1); - - // Older than the gateway's 10s lease, far younger than the 120s fallback. - const g = globalThis as Record; - g.__solutionSnapshot = { - ...(g.__solutionSnapshot as object), - expiresAt: 0, - fetchedAt: Date.now() - 11_000, - }; - - expect(await loadSolutions()).toBe("unavailable"); + expect(state.__solutionSnapshot).toBeNull(); }); // Unbounded, a wedged gateway never settles the fetch. Readers coalesce onto @@ -814,11 +778,7 @@ describe("registry snapshot", () => { ); await loadSolutions(); - const manifest = parseManifest(JSON.parse(manifestFor("a", 1))); - if (!manifest) throw new Error("fixture failed to parse"); - await registerSolution(manifest); - - expect(seen.length).toBeGreaterThanOrEqual(2); + expect(seen).toHaveLength(1); for (const init of seen) { expect( init?.signal, @@ -827,67 +787,6 @@ describe("registry snapshot", () => { } }); - // The TTL and the ceiling are independent windows. Serving on the TTL alone - // is only safe while the ceiling is the larger of the two, which nothing - // guarantees once the gateway reports the lease. - it("honours a lease shorter than the snapshot TTL", async () => { - const shortLease = new Response( - JSON.stringify({ - revision: 7, - leaseSeconds: 1, - solutions: [ - { id: "a", status: "active", manifest: manifestFor("a", 1) }, - ], - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(shortLease) - .mockRejectedValue(new Error("unreachable")), - ); - expect(await loadSolutions()).toHaveLength(1); - - // Past the 1s lease but still inside the 5s TTL: the fast path must not - // serve it just because the TTL has not elapsed. - const g = globalThis as Record; - g.__solutionSnapshot = { - ...(g.__solutionSnapshot as object), - fetchedAt: Date.now() - 2_000, - expiresAt: Date.now() + 3_000, - }; - expect(await loadSolutions()).toBe("unavailable"); - }); - - // The ceiling is a safety bound, so the far side must not be able to set it - // to "effectively never". - it("clamps an out-of-range reported lease", async () => { - // 1e999 parses to Infinity; an unchecked `> 0` accepts it and the ceiling - // silently never trips again. - const absurd = new Response( - `{"revision":7,"leaseSeconds":1e999,"solutions":[{"id":"a","status":"active","manifest":${JSON.stringify(manifestFor("a", 1))}}]}`, - { status: 200, headers: { "content-type": "application/json" } }, - ); - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(absurd) - .mockRejectedValue(new Error("unreachable")), - ); - expect(await loadSolutions()).toHaveLength(1); - - const g = globalThis as Record; - g.__solutionSnapshot = { - ...(g.__solutionSnapshot as object), - expiresAt: 0, - fetchedAt: Date.now() - 3_600_001, - }; - expect(await loadSolutions()).toBe("unavailable"); - }); - it("drops a stored manifest that no longer validates", async () => { vi.stubGlobal( "fetch", @@ -904,34 +803,7 @@ describe("registry snapshot", () => { expect(await loadSolutions()).toEqual([]); }); - it("maps a registry refusal onto a typed write result", async () => { - const manifest = parseManifest(JSON.parse(manifestFor("a", 1))); - if (!manifest) throw new Error("fixture failed to parse"); - for (const [status, reason] of [ - [409, "conflict"], - [403, "forbidden"], - [500, "unavailable"], - ] as const) { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("", { status })), - ); - expect(await registerSolution(manifest)).toEqual({ ok: false, reason }); - } - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response(JSON.stringify({ revision: 4, status: "active" }), { - status: 200, - }), - ), - ); - expect(await unregisterSolution("a")).toMatchObject({ - ok: true, - revision: 4, - }); - }); + }); describe("surfacesProjection for a solution served through the host", () => { @@ -955,13 +827,222 @@ describe("surfacesProjection for a solution served through the host", () => { })(); it("resolves modules against this host's origin, under the solution's proxy", () => { - expect(surfacesProjection(manifest, "word", "https://app.example")).toMatchObject({ + expect(surfacesProjection(manifest, "word", granted, "https://app.example")).toMatchObject({ origin: "https://app.example", surfaces: [{ module: "/api/solutions/audit/proxy/assets/surfaces/footnote.js" }], }); }); it("leaves the solution out when there is no host origin to resolve against", () => { - expect(surfacesProjection(manifest, "word")).toBeNull(); + expect(surfacesProjection(manifest, "word", granted)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Per-organization, per-viewer narrowing (issue #949) +// --------------------------------------------------------------------------- + +function entitlements( + overrides: Partial & { + solutions?: SolutionEntitlement[]; + } = {}, +): ViewerEntitlements { + const { solutions = [granted], ...rest } = overrides; + return { + org: "org-acme", + viewer: "viewer-1", + byTarget: new Map( + solutions.map((entitlement) => [entitlement.targetId, entitlement]), + ), + revision: solutions.map((s) => `${s.targetId}:${s.healthy}`).join("|"), + ...rest, + }; +} + +function manifestFor(id: string) { + const parsed = parseManifest( + baseManifest({ id, nav: { title: id, path: `/s/${id}` } }), + ); + if (parsed === null) throw new Error("fixture manifest must parse"); + // Stamped exactly as manifestsFromSnapshot stamps it: the target is the + // host's fact about the record, not a field of the document. + return { ...parsed, targetId: targetFor(id) }; +} + +describe("entitledSolutions", () => { + it("leaves out a deployed solution the organization has not installed", () => { + // The whole point of the narrowing: registered is not the same as usable. + const registered = [manifestFor("audit"), manifestFor("ledger")]; + const pairs = entitledSolutions(registered, entitlements()); + expect(pairs.map(({ manifest }) => manifest.id)).toEqual(["audit"]); + }); + + it("gives two teams in one organization genuinely different sets", () => { + const registered = [ + manifestFor("audit"), + manifestFor("ledger"), + manifestFor("intake"), + ]; + // Same org, same registered set, different grants — which is the acceptance + // case: the answer must be a function of the viewer, not of the deployment. + const reviewers = entitlements({ + viewer: "viewer-in-reviewers", + solutions: [granted], + }); + const clerks = entitlements({ + viewer: "viewer-in-clerks", + solutions: [ + { targetId: targetFor("ledger"), healthy: true, scopeNodeId: "node-ledger" }, + { targetId: targetFor("intake"), healthy: true, scopeNodeId: "node-intake" }, + ], + }); + expect( + entitledSolutions(registered, reviewers).map(({ manifest }) => manifest.id), + ).toEqual(["audit"]); + expect( + entitledSolutions(registered, clerks).map(({ manifest }) => manifest.id), + ).toEqual(["ledger", "intake"]); + }); + + it("narrows after a revocation", () => { + const registered = [manifestFor("audit"), manifestFor("ledger")]; + const before = entitlements({ + solutions: [ + granted, + { targetId: targetFor("ledger"), healthy: true, scopeNodeId: "node-ledger" }, + ], + }); + expect(entitledSolutions(registered, before)).toHaveLength(2); + // The grant on `ledger` is revoked: the authority stops returning it, so the + // projection stops carrying it. Nothing about the registration changed. + const after = entitlements({ solutions: [granted] }); + expect( + entitledSolutions(registered, after).map(({ manifest }) => manifest.id), + ).toEqual(["audit"]); + }); + + it("ignores an entitlement for a solution this deployment does not serve", () => { + // An org can hold an installation for a solution that was deregistered or + // has not registered yet. That is not an error to report into a menu. + const pairs = entitledSolutions( + [manifestFor("audit")], + entitlements({ + solutions: [ + granted, + { targetId: targetFor("retired"), healthy: true, scopeNodeId: "node-retired" }, + ], + }), + ); + expect(pairs.map(({ manifest }) => manifest.id)).toEqual(["audit"]); + }); +}); + +describe("projection availability", () => { + it("keeps an unhealthy installation visible and marks it unavailable", () => { + // Visible, because the org installed it and the viewer was granted it — + // hiding it sends someone looking for a grant that already exists. Not + // available, because it must not be routed as though it were serving. + expect(navProjection(manifestFor("audit"), grantedButUnhealthy)).toEqual({ + id: "audit", + nav: { title: "audit", path: "/s/audit" }, + available: false, + }); + }); + + it("marks a healthy installation available", () => { + expect(navProjection(manifestFor("audit"), granted).available).toBe(true); + }); +}); + +describe("cachedProjection", () => { + beforeEach(() => { + invalidateProjections(); + }); + + it("recomputes when the authority revision moves", () => { + const compute = vi.fn(() => ["audit"]); + const before = entitlements(); + expect(cachedProjection(before, "word", 1, compute)).toEqual(["audit"]); + // Same key: reused. + cachedProjection(before, "word", 1, compute); + expect(compute).toHaveBeenCalledTimes(1); + // A revoke moves the revision, so the key moves and the menu is recomputed. + // Without this a cached projection outlives the grant that justified it — + // the one failure this cache key exists to prevent. + cachedProjection(entitlements({ solutions: [] }), "word", 1, compute); + expect(compute).toHaveBeenCalledTimes(2); + }); + + it("does not share a projection between organizations, viewers or kinds", () => { + const compute = vi.fn(() => ["audit"]); + const base = entitlements(); + cachedProjection(base, "word", 1, compute); + cachedProjection({ ...base, org: "org-other" }, "word", 1, compute); + cachedProjection({ ...base, viewer: "viewer-2" }, "word", 1, compute); + cachedProjection(base, "excel", 1, compute); + // Four distinct keys — one per dimension the projection depends on. + expect(compute).toHaveBeenCalledTimes(4); + }); + + it("recomputes when the registered set moves under an unchanged grant", () => { + const compute = vi.fn(() => ["audit"]); + const base = entitlements(); + cachedProjection(base, "word", 1, compute); + // A re-registration with a new nav title moves no grant, so the authority + // revision is identical; the registry revision is what must catch it. + cachedProjection(base, "word", 2, compute); + expect(compute).toHaveBeenCalledTimes(2); + }); +}); + +describe("an entitlement that can never join a registration", () => { + beforeEach(() => { + (globalThis as Record).__unjoinableSolutionIdentifiers = + undefined; + }); + + it("is reported once when a viewer is entitled to a target the registry does not serve", () => { + // Ordinary during a redeploy, so it is debug volume rather than an error — + // but reported ONCE per target, because a lasting mismatch means presence + // and installation have genuinely diverged and nothing else would say so. + const logged = vi.spyOn(console, "debug").mockImplementation(() => {}); + try { + const unserved = entitlements({ + solutions: [ + granted, + { + targetId: "target-not-currently-served", + healthy: true, + scopeNodeId: "node-x", + }, + ], + }); + entitledSolutions([manifestFor("audit")], unserved); + entitledSolutions([manifestFor("audit")], unserved); + const reports = logged.mock.calls.filter((call) => + String(call[0]).includes("target-not-currently-served"), + ); + expect(reports).toHaveLength(1); + } finally { + logged.mockRestore(); + } + }); + + it("does not report a slug-shaped id this deployment simply does not serve", () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + entitledSolutions( + [manifestFor("audit")], + entitlements({ + solutions: [ + granted, + { targetId: targetFor("retired"), healthy: true, scopeNodeId: "node-retired" }, + ], + }), + ); + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } }); }); diff --git a/module/services/frontend/code/src/solutions/entitlements.ts b/module/services/frontend/code/src/solutions/entitlements.ts new file mode 100644 index 000000000..8e3f95b18 --- /dev/null +++ b/module/services/frontend/code/src/solutions/entitlements.ts @@ -0,0 +1,318 @@ +import "server-only"; + +import { getEndpoints, getWorkspaceSecret } from "codefly"; + +/** + * What the calling viewer may use (issue #949). + * + * The solution projections used to answer the same deployment-wide set to every + * caller. Narrowing them needs two things this process cannot establish on its + * own: the viewer's verified organization, and which installed solutions that + * viewer was granted. + * + * Neither may be derived here. `lib/auth-session.ts` can decode an access token, + * but `decodeJWTPayload` only base64-decodes it — it verifies NOTHING — so an + * organization read from a claim in a route handler is an organization the caller + * chose, and reading another tenant's menu would be a matter of editing one + * field. These routes are also not proxied through the gateway (`src/proxy.ts` + * forwards `/v1/*` and `/saas.accounts.v1.*`; every `/api/*` path is served + * in-process), so nothing stamps a verified identity on the incoming request + * either — and a caller-supplied `x-org-id` is exactly what this app strips + * elsewhere. + * + * So the question is asked of the component that authenticates. The gateway runs + * `ext_authz` on the credential this module forwards, projects the organization + * and viewer from it, and answers from the installation set and the scope-grant + * union accounts resolves in one transaction. What comes back is verified + * identity plus authority — not an echo of anything a browser said. + */ + +/** One installed solution the viewer may use. */ +export interface SolutionEntitlement { + /** + * The immutable solution TARGET this entitlement admits — one continuous + * period of one binding's presence on the host, never reused. + * + * It replaced the registered solution id, which is the route alias. An alias + * is deliberately reusable by a later binding, so joining a menu on it let a + * REPLACEMENT solution inherit the predecessor's entitlement: it appeared in + * the viewer's menu, under the predecessor's team grant, with nobody having + * acted. A target cannot be reused, so it cannot be inherited. + */ + targetId: string; + /** + * Whether the installation is healthy right now. An unhealthy one is still + * entitled — the org installed it and the viewer was granted it — so it stays + * in the projection marked unavailable rather than disappearing, which would + * send someone looking for a grant that already exists. + */ + healthy: boolean; + /** The scope node whose grant admitted it. */ + scopeNodeId: string; +} + +/** The verified viewer, and everything that viewer may use. */ +export interface ViewerEntitlements { + /** The organization the GATEWAY verified — never one this process decoded. */ + org: string; + /** The viewer the gateway verified. */ + viewer: string; + /** + * Keyed on the solution TARGET, which is what a consumer must join on. It is + * named for the key rather than left as `byId`, so a reader cannot assume the + * old alias join still works by reading the field name. + */ + byTarget: Map; + /** + * A fingerprint of the entitled set, for a consumer keying a cache on it. + * + * It is computed from the answer rather than reported by the authority: a + * digest of exactly the rows received is exact by construction, while a + * server-side revision would have to describe either one page or a set larger + * than was read. A grant, a revoke, an install, an uninstall or a health change + * all move it, because all of them change these rows — and a grant that changes + * none of them leaves it still, which is correct: the projection really is + * identical. + */ + revision: string; +} + +/** + * Why an entitlement read could not be answered. `unauthenticated` is the + * caller's own credential being refused, which a projection must relay as 401 + * rather than as an empty list: `[]` would read as "nothing is offered for you" + * and is indistinguishable from "your organization installed nothing". + * `unavailable` is this replica being unable to ask at all — a 503, for the same + * reason an unreadable registry is one. + */ +export type EntitlementFailure = + | "unauthenticated" + | "no_organization" + | "forbidden" + | "rate_limited" + | "unavailable"; + +const GATEWAY_ENTITLEMENTS_PATH = "/solutions/_entitlements"; +const INTERNAL_TOKEN_HEADER = "X-Codefly-Internal-Token"; + +/** + * The header the gateway uses to name its OWN refusals on this endpoint, and the + * values it sends (gateway_solution_entitlements.go). Status alone cannot + * separate them: a refused cluster-internal token and a refused user bearer are + * both 401, and reading the first as the second would tell every signed-in user + * to re-authenticate over a deployment fault — and drive a refresh-token rotation + * on every menu poll. + */ +const REFUSAL_HEADER = "X-Codefly-Entitlement-Refusal"; +const REFUSAL_INTERNAL_CREDENTIAL = "internal-credential"; +const REFUSAL_NO_ORGANIZATION = "no-organization"; + +/** + * How long the gateway gets to answer. It asks accounts under its own 10s budget, + * so this is a little longer; the point is that it is finite. A viewer's menu + * blocks on it, so a stalled authority must become a failed projection rather + * than a hung page. + */ +const ENTITLEMENTS_READ_TIMEOUT_MS = 12_000; + +/** + * The auth-gateway origin, from Codefly service discovery. It must NOT come from + * the incoming request's Host header: routing a server-side fetch through a + * client-controlled host is an SSRF sink. Same rule, same reason, as the registry + * snapshot read in registry.ts. + */ +function gatewayOrigin(): string | null { + const endpoint = getEndpoints().find( + (candidate) => + candidate.service === "auth-gateway" && candidate.name === "rest", + ); + if (!endpoint?.address) return null; + try { + return new URL(endpoint.address).origin; + } catch { + return null; + } +} + +function internalToken(): string | null { + const token = getWorkspaceSecret( + "internal-auth", + "CODEFLY_INTERNAL_TOKEN", + )?.trim(); + return token ? token : null; +} + +interface GatewayEntitlementEntry { + targetId?: unknown; + healthy?: unknown; + scopeNodeId?: unknown; +} + +/** + * A stable digest of the entitled set. + * + * Sorted before hashing so the fingerprint depends on the SET and not on the + * order it arrived in: the authority orders by installation id, which is stable + * but says nothing about solution id, and a reordering that changed the key would + * evict a still-correct cache entry on every read. `healthy` is part of the + * digest because it is part of the projection — a solution going unhealthy must + * re-render as unavailable. + */ +function entitlementRevision(entitlements: SolutionEntitlement[]): string { + const canonical = entitlements + .map( + (entitlement) => + `${entitlement.targetId}\u0000${entitlement.scopeNodeId}\u0000${entitlement.healthy ? "1" : "0"}`, + ) + .sort() + .join("\u0001"); + // FNV-1a over the canonical form. This is a cache key, never a security + // boundary — nothing authorizes on it, and a collision costs a stale + // projection for one (org, viewer) until the next change, not an escalation. + // A cryptographic digest would mean an async subtle-crypto call on the path of + // every menu read for no property that is used. + let hash = 0x811c9dc5; + for (let index = 0; index < canonical.length; index += 1) { + hash ^= canonical.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + // Length travels with the hash so two different sets cannot share a key on the + // 32-bit value alone. + return `${(hash >>> 0).toString(16)}.${entitlements.length}`; +} + +function parseEntitlements(body: unknown): ViewerEntitlements | null { + if (typeof body !== "object" || body === null) return null; + const { org, viewer, solutions } = body as { + org?: unknown; + viewer?: unknown; + solutions?: unknown; + }; + // The organization and viewer are the point of this answer: without them the + // caller has a list it cannot attribute, and a cache it cannot key. A response + // missing either is malformed, not an empty entitlement set. + if (typeof org !== "string" || org === "") return null; + if (typeof viewer !== "string" || viewer === "") return null; + if (!Array.isArray(solutions)) return null; + const parsed: SolutionEntitlement[] = []; + for (const entry of solutions as GatewayEntitlementEntry[]) { + if (typeof entry !== "object" || entry === null) return null; + if (typeof entry.targetId !== "string" || entry.targetId === "") return null; + if (typeof entry.healthy !== "boolean") return null; + parsed.push({ + targetId: entry.targetId, + healthy: entry.healthy, + scopeNodeId: + typeof entry.scopeNodeId === "string" ? entry.scopeNodeId : "", + }); + } + return { + org, + viewer, + byTarget: new Map( + parsed.map((entitlement) => [entitlement.targetId, entitlement]), + ), + revision: entitlementRevision(parsed), + }; +} + +/** + * Ask the gateway what the credential on `request` may use. + * + * The caller's `authorization` header is forwarded verbatim and is the only thing + * that decides whose entitlements come back. It is deliberately not inspected + * here: which credentials authenticate — a session bearer, an API key, a + * registered client's token — is the gateway's judgement, and a second opinion in + * this process would be a second perimeter to keep in step. A request with no + * credential is forwarded too, so the 401 comes from the one component that + * authenticates rather than from a guess made here. + */ +export async function viewerEntitlements( + request: Request, +): Promise { + const origin = gatewayOrigin(); + const token = internalToken(); + if (!origin || !token) { + console.error( + "solution entitlements: gateway endpoint or internal token unresolved", + ); + return "unavailable"; + } + const headers = new Headers({ + [INTERNAL_TOKEN_HEADER]: token, + accept: "application/json", + }); + const presented = request.headers.get("authorization"); + if (presented) headers.set("authorization", presented); + + let response: Response; + try { + response = await fetch(`${origin}${GATEWAY_ENTITLEMENTS_PATH}`, { + headers, + cache: "no-store", + signal: AbortSignal.timeout(ENTITLEMENTS_READ_TIMEOUT_MS), + }); + } catch (err) { + console.error("solution entitlements: gateway unreachable", err); + return "unavailable"; + } + const refusal = response.headers.get(REFUSAL_HEADER); + if (refusal === REFUSAL_INTERNAL_CREDENTIAL) { + // This process's own credential, not the viewer's. An outage of this + // replica, answered 503 — never relayed as the user being signed out. + console.error( + "solution entitlements: the gateway refused this frontend's internal credential", + ); + return "unavailable"; + } + if (response.status === 401) return "unauthenticated"; + // Authenticated, but with no organization to answer for. Distinct from 401: + // re-authenticating would not change it, so a consumer must not be sent back + // through login. + if (refusal === REFUSAL_NO_ORGANIZATION) return "no_organization"; + // A 403 verdict from ext_authz on the user's credential. + if (response.status === 403) return "forbidden"; + // The viewer's organization spent its read budget. Relayed as 429 so a client + // backs off, rather than as an outage it would retry immediately. + if (response.status === 429) return "rate_limited"; + if (!response.ok) { + console.error(`solution entitlements: gateway answered ${response.status}`); + return "unavailable"; + } + const parsed = parseEntitlements(await response.json().catch(() => null)); + if (parsed === null) { + console.error("solution entitlements: malformed gateway answer"); + return "unavailable"; + } + return parsed; +} + +/** + * The response a projection route answers for a failed entitlement read — one + * mapping, so the two routes cannot drift into describing the same failure + * differently. None of these is an empty list: each is a reason the caller acts on + * differently (sign in, pick an organization, back off, retry later). + */ +export function entitlementFailureResponse( + failure: EntitlementFailure, +): Response { + switch (failure) { + case "unauthenticated": + return Response.json({ error: "unauthenticated" }, { status: 401 }); + case "no_organization": + return Response.json({ error: "no_organization" }, { status: 403 }); + case "forbidden": + return Response.json({ error: "forbidden" }, { status: 403 }); + case "rate_limited": + return Response.json({ error: "rate_limited" }, { status: 429 }); + case "unavailable": + return Response.json({ error: "authority_unavailable" }, { status: 503 }); + } +} + +/** Narrows a read's result to its failure, for the routes' early return. */ +export function isEntitlementFailure( + result: ViewerEntitlements | EntitlementFailure, +): result is EntitlementFailure { + return typeof result === "string"; +} diff --git a/module/services/frontend/code/src/solutions/projections.ts b/module/services/frontend/code/src/solutions/projections.ts new file mode 100644 index 000000000..f58bb1660 --- /dev/null +++ b/module/services/frontend/code/src/solutions/projections.ts @@ -0,0 +1,281 @@ +import "server-only"; + +import type { + SolutionEntitlement, + ViewerEntitlements, +} from "@/solutions/entitlements"; +import { + type SolutionClientSurfaces, + type SolutionManifest, + type SolutionNav, + solutionProxyBase, +} from "@/solutions/registry"; + +/** + * The per-viewer solution projections (issue #949): the navigation menu and the + * per-client surface listing, narrowed to what one viewer may use. + * + * Kept apart from registry.ts on purpose. The registry decides what is + * REGISTERED — and through findSolution, whether `/s/{id}` renders at all — and + * that stays deployment-wide and blind to any tenant's installations. This file + * is the one place that joins the registered set against a viewer's entitlements. + * module/tools/solution_registration_boundary_test.go holds the two apart: it + * scans registry.ts for installation coupling, and would fail if this narrowing + * moved back into it. + */ + +/** + * The registered solutions this viewer may use, each paired with the entitlement + * that admitted it, in the order the registry ordered them. + * + * The join key is the immutable solution TARGET: `installations.target_id` on the + * authority side and `manifest.targetId` here, which the registry projection + * carries from the declaration that produced the record. + * + * It used to be the solution id — the route alias — on both sides. An alias is + * deliberately reusable by a later binding, so that join made a REPLACEMENT + * solution inherit the predecessor's menu entry and its team exposure, with no + * administrator having acted. The target is one continuous period of one + * binding's presence and is never reused, so the replacement joins to nothing. + * + * A registered solution with no entitlement is absent: deployed but uninstalled, + * or installed but not granted to this viewer, are both invisible. An entitlement + * with no registration is absent too, and deliberately quiet — an organization may + * hold an installation for a solution this deployment does not serve (it was + * deregistered, or has not registered yet), and that is not an error to report to + * the viewer whose menu is being drawn. + */ +export function entitledSolutions( + registered: SolutionManifest[], + entitlements: ViewerEntitlements, +): Array<{ manifest: SolutionManifest; entitlement: SolutionEntitlement }> { + const pairs: Array<{ + manifest: SolutionManifest; + entitlement: SolutionEntitlement; + }> = []; + const joined = new Set(); + for (const manifest of registered) { + // A registered record that nothing declared carries no target. It is + // joinable to no entitlement, which is the fail-closed answer: presence + // nobody declared is presence nobody could have consented to. + if (manifest.targetId === "") continue; + const entitlement = entitlements.byTarget.get(manifest.targetId); + if (entitlement === undefined) continue; + joined.add(manifest.targetId); + pairs.push({ manifest, entitlement }); + } + for (const target of entitlements.byTarget.keys()) { + if (!joined.has(target)) reportUnjoinableEntitlement(target); + } + return pairs; +} + +// Identifiers already reported, process-wide. On globalThis for the reason the +// registry snapshot is: Next evaluates route handlers in separate module graphs, +// and a per-graph set would report the same identifier once per graph. +const globalForUnjoinable = globalThis as typeof globalThis & { + __unjoinableSolutionIdentifiers?: Set; +}; + +/** + * Say, once per identifier, that an organization holds an installation that no + * registration can ever match. + * + * Under the alias join this reported a shape mismatch — an installation naming + * something that was not a slug could never match a registered id. The target + * join has no shape to mismatch: both sides are uuids assigned by the host. What + * remains worth saying is narrower and still real: the viewer holds an + * entitlement for a target the registry does not currently serve. + * + * That is ORDINARY and so it is reported at debug volume, not as an error. An + * organisation keeps an installation while the solution is restarting, being + * redeployed, or temporarily withdrawn; the host withdrawing presence revokes the + * installation in the same transaction, so a lasting mismatch means the two have + * genuinely diverged and an operator should be able to see it without it crying + * wolf on every rollout. + */ +function reportUnjoinableEntitlement(id: string): void { + if (!globalForUnjoinable.__unjoinableSolutionIdentifiers) { + globalForUnjoinable.__unjoinableSolutionIdentifiers = new Set(); + } + const reported = globalForUnjoinable.__unjoinableSolutionIdentifiers; + if (reported.has(id)) return; + reported.add(id); + console.debug( + `solution projections: this viewer is entitled to solution target ${JSON.stringify(id)}, which the registry does not currently serve, so it appears in no projection. Ordinary during a redeploy; lasting means presence and installation have diverged.`, + ); +} + +/** + * The cache of computed projections, keyed on everything a projection is a + * function of. + * + * The snapshot cache above it is deployment-wide, because the registered set is. + * A projection is not: it depends on the viewer's organization, on the authority + * state that admitted each solution, and on the client kind asked for. So the key + * carries all four. + * + * The revision is a digest of the entitled set itself (see entitlements.ts), which + * is what makes this safe: a grant or a revoke changes the set, so it changes the + * key, so a cached menu cannot outlive the grant that justified it. A TTL could not + * give that — it would only bound how long a revoked viewer keeps seeing a solution. + * + * Note what this does NOT cache: the authority answer. Every request asks the + * gateway. What is reused is the manifest shaping, on a key that provably moves + * whenever authority does. + */ +const PROJECTION_CACHE_LIMIT = 256; + +const globalForProjections = globalThis as typeof globalThis & { + __solutionProjectionCache?: Map; +}; + +function projectionCache(): Map { + // On globalThis for the same reason the snapshot is: Next evaluates route + // handlers in separate module graphs, so a module-level Map would be one cache + // per graph, each missing what the others computed. + if (!globalForProjections.__solutionProjectionCache) { + globalForProjections.__solutionProjectionCache = new Map(); + } + return globalForProjections.__solutionProjectionCache; +} + +/** + * Memoize a projection on (organization, viewer, authority revision, client kind, + * registry revision). + * + * The registry revision is in the key because the registered set is the other + * input: a solution re-registering with a new nav title must re-render even though + * no grant moved. + * + * Eviction is oldest-first at a fixed ceiling rather than by time. Every key + * component is a content fingerprint, so an entry is never stale — only unused — + * and the ceiling exists to bound memory across viewers, not to expire anything. + */ +export function cachedProjection( + entitlements: ViewerEntitlements, + client: string, + registryRevision: number, + compute: () => T, +): T { + const key = [ + entitlements.org, + entitlements.viewer, + entitlements.revision, + client, + String(registryRevision), + ].join("\u0000"); + const cache = projectionCache(); + const hit = cache.get(key); + if (hit !== undefined) return hit as T; + const computed = compute(); + if (cache.size >= PROJECTION_CACHE_LIMIT) { + const oldest = cache.keys().next(); + if (!oldest.done) cache.delete(oldest.value); + } + cache.set(key, computed); + return computed; +} + +/** + * Drop every cached projection. + * + * Exposed for tests. A registry write does NOT need it: the registry revision is + * part of every key, so a write that changes the registered set changes the key, + * and a reconciliation that changes nothing leaves a projection that + * is still correct. + */ +export function invalidateProjections(): void { + globalForProjections.__solutionProjectionCache = new Map(); +} + +/** + * What one signed-in viewer may read: the id and the nav entry the Solutions menu + * renders, for a solution that viewer may actually use. A manifest also carries + * deployment topology — the origin the solution's code is served from, the backend + * service that fronts it, and its dashboard declaration — which no browser needs + * to render a link, so the projection drops it rather than shipping it to every + * poll. + * + * The entitlement is a REQUIRED argument, not an optional narrowing. A parameter + * that could be omitted would widen this projection back to the deployment-wide + * set the moment a caller forgot it, and the failure would look like working code: + * every viewer would see every registered solution, which is exactly the defect + * this narrowing removes. Requiring it means a caller cannot project without + * having asked the authority. + */ +export function navProjection( + manifest: SolutionManifest, + entitlement: SolutionEntitlement, +): SolutionNav { + return { + id: manifest.id, + nav: { ...manifest.nav }, + available: entitlement.healthy, + }; +} + +/** + * What one kind of client may read: the surfaces declared for that kind, named + * by the solution offering them. A client asks for its own kind and gets + * exactly what applies to it, so learning what is on offer no longer means + * shipping a table of who offers what inside every client. + * + * Registration is still deployment-wide; what a viewer may USE is not. The + * entitlement argument is that narrowing, and it is required for the reason + * navProjection's is: an optional one would silently restore the deployment-wide + * answer whenever a caller left it out. + * + * Returns null when this solution declares nothing for that client, which is + * how the listing leaves it out rather than listing it empty. + */ +export function surfacesProjection( + manifest: SolutionManifest, + client: string, + entitlement: SolutionEntitlement, + hostOrigin?: string, +): SolutionClientSurfaces | null { + const surfaces = (manifest.surfaces ?? []).filter( + (surface) => surface.client === client, + ); + if (surfaces.length === 0) { + return null; + } + // A solution served through this host has no origin of its own a client can + // reach: its modules are paths on its backend, which this host serves under + // the solution's proxy base. Without the host's own origin there is nothing + // to resolve them against, so the solution is left out rather than answered + // with an address that cannot work. + const servedByHost = manifest.frontend.manifestUrl.startsWith("/"); + if (servedByHost && !hostOrigin) { + return null; + } + const modulePath = (module: string) => + servedByHost ? `${solutionProxyBase(manifest.id)}${module}` : module; + return { + id: manifest.id, + title: manifest.nav.title, + available: entitlement.healthy, + // A declared module is a path on the solution's origin, so without the + // origin no caller can fetch one. The origin is not withheld topology + // here the way the manifest path is: the client fetches the module from + // it directly, and every signed-in document already carries it in the + // CSP that admits the same origin's code. + origin: servedByHost + ? (hostOrigin as string) + : new URL(manifest.frontend.manifestUrl).origin, + // A shallow copy would share `applies.tagged` and `events` with the + // cached snapshot, which outlives this response and is read by every + // later caller — including other client kinds, which read the same + // manifest objects. + surfaces: surfaces.map((surface) => ({ + ...surface, + module: modulePath(surface.module), + applies: + typeof surface.applies === "object" + ? { tagged: [...surface.applies.tagged] } + : surface.applies, + events: surface.events === undefined ? undefined : [...surface.events], + })), + }; +} diff --git a/module/services/frontend/code/src/solutions/registration-authority.ts b/module/services/frontend/code/src/solutions/registration-authority.ts deleted file mode 100644 index e9d655a1b..000000000 --- a/module/services/frontend/code/src/solutions/registration-authority.ts +++ /dev/null @@ -1,317 +0,0 @@ -import "server-only"; - -import { createPublicKey, type KeyObject, verify } from "node:crypto"; - -import { getEndpoints } from "codefly"; - -/** - * Owner-bound authority for solution registration — the frontend half. - * - * The gateway and this host accept the SAME credential: a short-lived, - * solution-bound token accounts mints against the publisher's own registration - * secret. Both sides verify it independently against the cluster's published - * key set, so neither has to hold a secret every registrant shares, and neither - * has to trust the other's decision. - * - * This half matters more than the gateway's, not less: a frontend registration - * decides which script the browser loads as a Module-Federation remote, and that - * code then runs in the host origin with the viewer's access token. See - * `module/SOLUTION_REGISTRATION.md` for the trust contract that follows from it. - */ - -/** Header carrying the signed per-solution registration token. */ -export const SOLUTION_REGISTRATION_HEADER = "x-codefly-solution-registration"; - -/** - * Audience scoping the token to this single purpose. An access token carries - * `saas-starter` and a module-registration token `module-registration`; even - * though all three are signed with the same key, the audience check makes them - * non-interchangeable — no access token can publish host-origin code. - */ -const REGISTRATION_AUDIENCE = "solution-registration"; - -/** Issuer accounts stamps on every token it mints (see the gateway's ExtAuthz). */ -const ISSUER = "saas-starter"; - -/** Tolerance for clock skew between accounts and this host, in seconds. */ -const CLOCK_SKEW_LEEWAY_SECONDS = 60; - -/** The gateway's public JWKS route, which republishes accounts' key set. */ -const JWKS_PATH = "/v1/auth/.well-known/jwks.json"; - -const JWKS_REQUEST_TIMEOUT_MS = 2_000; -const JWKS_MAX_BYTES = 256 * 1024; -/** Minimum spacing between refetches triggered by an unrecognised key id. */ -const JWKS_PROBE_INTERVAL_MS = 5_000; - -export interface SolutionRegistrationClaims { - /** Publisher identity accountable for the registration. */ - subject: string; - /** The single solution id this credential may register, update, or delete. */ - solution: string; - /** Token id, burned on use so a captured credential cannot be replayed. */ - jti: string; - /** Expiry, in epoch seconds. */ - expiresAt: number; -} - -function decodeSegment(segment: string): unknown { - return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); -} - -interface JwksState { - keys: Map; - fetchedAt: number; - /** - * Whether the most recent fetch failed to read the key set. An unknown key - * id after a SUCCESSFUL read is a token this cluster never signed; after a - * failed one it is a question this host could not answer, and the two are - * reported differently (see verifySolutionRegistration). - */ - lastFetchFailed: boolean; -} - -/** - * Why a presented credential was not accepted, when it was not: `invalid` is a - * verdict about the credential, `unavailable` is this host failing to reach the - * key set it verifies against — a restarting gateway, not a wrong registrant. - */ -export type SolutionRegistrationRefusal = "invalid" | "unavailable"; - -const globalForJwks = globalThis as typeof globalThis & { - __solutionRegistrationJwks?: JwksState; - __solutionRegistrationJtis?: Map; -}; - -/** Resolve the auth-gateway REST base from the Codefly SDK. */ -function gatewayBase(): string | null { - const endpoint = getEndpoints().find( - (candidate) => - candidate.service === "auth-gateway" && candidate.name === "rest", - ); - if (!endpoint?.address) { - return null; - } - try { - return new URL(endpoint.address).origin; - } catch { - return null; - } -} - -/** - * Fetch and parse the published key set, or null when it could not be read. Only - * Ed25519 signing keys are imported: the credential is EdDSA and nothing else is - * accepted, so a key set that grew an RSA entry cannot widen what this host will - * verify. - * - * Every failure — unreachable, non-2xx, oversized, unparseable, or carrying no - * usable key — returns null rather than an empty map, because the caller must be - * able to tell "the key set is now empty" (which no legitimate rotation - * produces) from "I could not read it", and only the latter may keep the keys it - * already has. - */ -async function fetchKeys(): Promise | null> { - const base = gatewayBase(); - if (!base) { - return null; - } - const response = await fetch(`${base}${JWKS_PATH}`, { - signal: AbortSignal.timeout(JWKS_REQUEST_TIMEOUT_MS), - cache: "no-store", - }); - if (!response.ok) { - return null; - } - const body = await response.text(); - if (body.length > JWKS_MAX_BYTES) { - return null; - } - const parsed = JSON.parse(body) as { keys?: unknown }; - const keys = new Map(); - if (!Array.isArray(parsed.keys)) { - return null; - } - for (const entry of parsed.keys) { - const jwk = entry as Record; - if ( - jwk.kty !== "OKP" || - jwk.crv !== "Ed25519" || - typeof jwk.kid !== "string" || - typeof jwk.x !== "string" - ) { - continue; - } - try { - keys.set( - jwk.kid, - createPublicKey({ - key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, - format: "jwk", - }), - ); - } catch { - // A malformed entry is skipped, not fatal: one bad key must not take - // the rest of a rotating key set out of service. - } - } - return keys.size > 0 ? keys : null; -} - -/** - * The verification key for `kid`, refetching at most once per probe interval - * when the id is unrecognised — which is what a key rotation looks like from - * here. Answers `unavailable` when the key set could not be read and holds no - * such key, so verification fails closed rather than falling back to an older - * trust decision — and says why, rather than calling the credential invalid. - */ -async function keyFor( - kid: string, -): Promise { - const cached = globalForJwks.__solutionRegistrationJwks; - const hit = cached?.keys.get(kid); - if (hit) { - return hit; - } - if (cached && Date.now() - cached.fetchedAt < JWKS_PROBE_INTERVAL_MS) { - return cached.lastFetchFailed ? "unavailable" : "invalid"; - } - let fetched: Map | null = null; - try { - fetched = await fetchKeys(); - } catch { - fetched = null; - } - // A refresh that FAILED must not discard keys that still verify: a single - // non-2xx during a gateway rollout would otherwise replace a good key set - // with an empty one and refuse every registration until some later fetch - // happened to succeed. Keep what we hold and only throttle the next attempt; - // a successful fetch replaces the set outright, which is what a rotation - // needs. - const keys = fetched ?? cached?.keys ?? new Map(); - globalForJwks.__solutionRegistrationJwks = { - keys, - fetchedAt: Date.now(), - lastFetchFailed: fetched === null, - }; - return keys.get(kid) ?? (fetched === null ? "unavailable" : "invalid"); -} - -/** - * Verify a presented registration credential. Fails closed on every defect: a - * missing or malformed token, an algorithm other than EdDSA, an unknown or - * unreachable key, a bad signature, the wrong issuer or audience, an expired or - * not-yet-valid token, or a token with no jti, subject, or solution binding. - * - * Every refusal is `invalid` except one: a key this host could not look up - * because the key set was unreachable is `unavailable`. The registrant cannot - * fix that one and must not be told its credential was refused — it was never - * judged. - */ -export async function verifySolutionRegistration( - token: string | null, -): Promise { - const verdict = await judge(token); - return verdict ?? "invalid"; -} - -async function judge( - token: string | null, -): Promise { - if (!token) { - return null; - } - const parts = token.split("."); - if (parts.length !== 3) { - return null; - } - const [headerSegment, payloadSegment, signatureSegment] = parts; - let header: Record; - let payload: Record; - try { - header = decodeSegment(headerSegment) as Record; - payload = decodeSegment(payloadSegment) as Record; - } catch { - return null; - } - // Alg-locked: the key set holds only Ed25519 keys, and "none" or a symmetric - // alg must never reach a verify call. - if (header.alg !== "EdDSA" || typeof header.kid !== "string") { - return null; - } - const key = await keyFor(header.kid); - if (typeof key === "string") { - return key; - } - let signatureValid = false; - try { - signatureValid = verify( - null, - Buffer.from(`${headerSegment}.${payloadSegment}`, "utf8"), - key, - Buffer.from(signatureSegment, "base64url"), - ); - } catch { - return null; - } - if (!signatureValid) { - return null; - } - - const audience = payload.aud; - const audiences = Array.isArray(audience) ? audience : [audience]; - if ( - payload.iss !== ISSUER || - !audiences.includes(REGISTRATION_AUDIENCE) || - typeof payload.sub !== "string" || - payload.sub === "" || - typeof payload.solution !== "string" || - payload.solution === "" || - typeof payload.jti !== "string" || - payload.jti === "" || - typeof payload.exp !== "number" - ) { - return null; - } - const now = Math.floor(Date.now() / 1000); - if (payload.exp + CLOCK_SKEW_LEEWAY_SECONDS < now) { - return null; - } - if ( - typeof payload.nbf === "number" && - payload.nbf - CLOCK_SKEW_LEEWAY_SECONDS > now - ) { - return null; - } - return { - subject: payload.sub, - solution: payload.solution, - jti: payload.jti, - expiresAt: payload.exp, - }; -} - -/** - * Burn a credential's jti, reporting false when it has already been used. A - * token is minted per attempt and lives minutes, so the set to remember is small - * and self-clearing: an entry is dropped once the token it names could no longer - * verify anyway. - */ -export function consumeRegistrationToken( - claims: SolutionRegistrationClaims, -): boolean { - const used = - globalForJwks.__solutionRegistrationJtis ?? - (globalForJwks.__solutionRegistrationJtis = new Map()); - const now = Math.floor(Date.now() / 1000); - for (const [jti, expiry] of used) { - if (expiry + CLOCK_SKEW_LEEWAY_SECONDS < now) { - used.delete(jti); - } - } - if (used.has(claims.jti)) { - return false; - } - used.set(claims.jti, claims.expiresAt); - return true; -} diff --git a/module/services/frontend/code/src/solutions/registration-log.ts b/module/services/frontend/code/src/solutions/registration-log.ts deleted file mode 100644 index ca2d95a8c..000000000 --- a/module/services/frontend/code/src/solutions/registration-log.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * What the registration endpoint says about a registrant, at state changes only. - * - * A solution re-registers on a heartbeat (every 15s by default, per surface), so - * a line per beat is a line per solution every few seconds, forever — it buried - * everything else this server printed. A beat that finds the registration as it - * left it is not an event: the lease it renews is already recorded by the - * registry. What an operator needs is the change — a solution registering, its - * registered record moving to a new revision, a registration starting to be - * refused (and why), a different refusal, and the recovery — so exactly those - * are logged, and every beat in between is only counted. The count rides on the - * next line this module prints for that solution ("after N beats"), which is the - * per-beat trace kept without a per-beat line. - * - * A standing refusal still says so again at {@link isStandingConditionMilestone} beats. Printing - * a refusal once and never again is a log that goes quiet while the thing is - * still broken, which is the failure the per-beat line at least did not have; - * the milestones grow by ten, so an hour of refusals costs three lines. - * - * A beat whose credential never verified belongs to no registrant — the id it - * claims is not believed — so it is not held here at all. It is an observation - * about the credential check itself, which - * {@link observeAuthorityRefusal} holds separately: keyed by the two things - * that can go wrong rather than by a stand-in registrant, so a key set outage - * and a refused credential happening at once do not evict each other's state - * and print on every beat, and so the key set coming back is a recovery this - * module can actually see — the first beat that verifies proves it. - * - * The registrant logs its own side of the same transitions (registered, rejected - * with a reason, backing off) — see the solution runtime's heartbeat — so the - * two logs read as one story without either repeating itself per beat. - * - * State is per process, like the rest of this server's registration caches: a - * restarted frontend logs each solution's first beat once, which is the right - * thing to say after a restart. - */ - -/** - * Why a registration was refused. - * - * A closed set on purpose. This value is the *identity* of the refusal, and a - * state that carries caller-supplied text is a state that differs on every - * beat — so a registrant that varied one field per beat would get a line per - * beat, which is what this module exists to stop. Everything variable travels - * as `detail`, which is printed but never compared. - */ -export type RegistrationRefusal = - | "invalid_json" - | "invalid_manifest" - | "solution_not_authorized" - | "incompatible_runtime" - | `registry ${"unavailable" | "conflict" | "forbidden" | "rejected"}`; - -/** A registration beat's outcome, as the endpoint answered it. */ -export type RegistrationBeat = - | { ok: true; revision: number; status: string } - | { - ok: false; - httpStatus: number; - reason: RegistrationRefusal; - detail?: string; - }; - -/** - * What the credential check answered for a beat that never reached a - * registrant: the key set could not be reached to judge the credential, or it - * was judged and refused. The two are tracked apart because only the first has - * a recovery — a later beat that verifies proves the key set is reachable, and - * proves nothing about whoever presented a credential this host rejected. - */ -export type AuthorityRefusal = "unreachable" | "refused"; - -const AUTHORITY_TEXT: Record = { - unreachable: - "the registration key set is unreachable; beats are answered 503 rather than having their credential refused", - refused: "a beat presented a credential this host does not accept (401)", -}; - -type Held = - | { ok: true; revision: number; status: string; beats: number } - | { - ok: false; - httpStatus: number; - reason: RegistrationRefusal; - detail?: string; - beats: number; - }; - -/** Distinct registrants tracked before the oldest is forgotten. */ -const MAX_TRACKED = 256; - -/** How much caller-supplied text a line may carry. */ -const MAX_DETAIL = 200; - -const globalForLog = globalThis as { - __solutionRegistrationLog?: Map; - __solutionRegistrationAuthority?: Map; -}; - -function held(): Map { - globalForLog.__solutionRegistrationLog ??= new Map(); - return globalForLog.__solutionRegistrationLog; -} - -function authority(): Map { - globalForLog.__solutionRegistrationAuthority ??= new Map(); - return globalForLog.__solutionRegistrationAuthority; -} - -/** - * Render a caller-supplied value as one bounded line. - * - * A manifest id, a compatibility reason and a registry status all reach a log - * line from the request body or from another service. A newline in one of them - * writes a second record that reads exactly like a real one, so the module that - * owns the record boundary is the one that has to hold it: control characters - * are escaped here rather than trusted to every producer upstream. - */ -function oneLine(value: string): string { - const escaped = value.replace( - // biome-ignore lint/suspicious/noControlCharactersInRegex: escaping them is the point - /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, - (character) => - `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`, - ); - return escaped.length > MAX_DETAIL - ? `${escaped.slice(0, MAX_DETAIL)}…` - : escaped; -} - -function after(beats: number): string { - return beats === 1 ? "after 1 beat" : `after ${beats} beats`; -} - -/** - * Whether a standing condition that has now lasted `occurrences` should say so - * again. True at 10, 100, 1000 … so the line count grows with the logarithm of - * the outage rather than with the outage. - * - * Exported because the snapshot read in registry.ts reports its own standing - * failure the same way, and two copies of this rule would drift into two - * cadences for one subsystem's logs. - */ -export function isStandingConditionMilestone(occurrences: number): boolean { - if (occurrences < 10) { - return false; - } - let threshold = 10; - while (threshold < occurrences) { - threshold *= 10; - } - return threshold === occurrences; -} - -/** The refusal as a line reads it: its reason, and its detail when it has one. */ -function refusalText(beat: { - reason: RegistrationRefusal; - detail?: string; -}): string { - return beat.detail === undefined - ? beat.reason - : `${beat.reason} (${oneLine(beat.detail)})`; -} - -/** - * Record one beat for `solution` and log it only when it changes what is known - * about that registration, or when a standing refusal reaches a milestone. - * Returns the line it logged, or undefined for a beat that changed nothing. - */ -export function observeRegistrationBeat( - solution: string, - beat: RegistrationBeat, -): string | undefined { - const states = held(); - const name = oneLine(solution); - const previous = states.get(solution); - const unchanged = - previous !== undefined && - (beat.ok - ? previous.ok && - previous.revision === beat.revision && - previous.status === beat.status - : !previous.ok && - previous.httpStatus === beat.httpStatus && - previous.reason === beat.reason); - if (unchanged && previous !== undefined) { - previous.beats += 1; - // Recency is what the bound below evicts on, and a registration that - // beats steadily without changing is the most recently seen of all. Left - // where its last *change* put it, it is the entry a churning neighbour - // evicts first — and its next beat would then log as a fresh - // registration that never happened. - states.delete(solution); - states.set(solution, previous); - if (!previous.ok && isStandingConditionMilestone(previous.beats)) { - const line = `solution registration: "${name}" is still refused ${previous.httpStatus} ${refusalText(previous)} ${after(previous.beats)}`; - console.warn(line); - return line; - } - return undefined; - } - - let line: string; - if (beat.ok) { - const status = oneLine(beat.status); - if (previous === undefined) { - line = `solution registration: "${name}" registered (revision ${beat.revision}, ${status})`; - } else if (!previous.ok) { - line = `solution registration: "${name}" registered again (revision ${beat.revision}, ${status}) — recovered from ${previous.httpStatus} ${refusalText(previous)} ${after(previous.beats)}`; - } else { - line = `solution registration: "${name}" now at revision ${beat.revision} (${status}; was revision ${previous.revision}, ${oneLine(previous.status)}, held ${after(previous.beats)})`; - } - console.info(line); - } else { - const since = - previous === undefined - ? "" - : previous.ok - ? ` — was registered at revision ${previous.revision} ${after(previous.beats)}` - : ` — was refused ${previous.httpStatus} ${refusalText(previous)} ${after(previous.beats)}`; - line = `solution registration: "${name}" refused ${beat.httpStatus} ${refusalText(beat)}${since}; the same refusal is reported again only at 10, 100, 1000 … beats, or when it changes`; - console.warn(line); - } - - // Insertion order is recency order: every beat re-inserts, so the oldest key - // is the one nothing has been heard from for longest. - states.delete(solution); - states.set(solution, { ...beat, beats: 1 }); - if (states.size > MAX_TRACKED) { - const oldest = states.keys().next().value; - if (oldest !== undefined) states.delete(oldest); - } - return line; -} - -/** - * Record that a beat never got past the credential check. Logged at the first - * beat of each distinct failure, and again at milestones, so an outage that - * lasts an hour is neither a line per beat nor a single line an hour ago. - */ -export function observeAuthorityRefusal( - refusal: AuthorityRefusal, -): string | undefined { - const modes = authority(); - const beats = (modes.get(refusal) ?? 0) + 1; - modes.set(refusal, beats); - if (beats !== 1 && !isStandingConditionMilestone(beats)) { - return undefined; - } - const line = `solution registration: ${AUTHORITY_TEXT[refusal]} — ${after(beats)}`; - console.warn(line); - return line; -} - -/** - * Record that a beat's credential verified, which is the only evidence this - * host has that the key set is reachable again. Closes an open `unreachable` - * run with the recovery line the refusal promised. - * - * A refused credential is deliberately NOT closed by this: someone else's beat - * verifying says nothing about the caller whose credential this host rejected, - * and clearing it would report a recovery that did not happen. - */ -export function observeAuthorityReachable(): string | undefined { - const modes = authority(); - const beats = modes.get("unreachable"); - if (beats === undefined) { - return undefined; - } - modes.delete("unreachable"); - const line = `solution registration: the registration key set is reachable again; it answered 503 for ${beats === 1 ? "1 beat" : `${beats} beats`}`; - console.info(line); - return line; -} - -/** - * Record that `solution` removed its own registration. A removal is always a - * change, and a later beat logs as a fresh registration. - */ -export function observeRegistrationRemoved( - solution: string, - revision: number, -): string { - const states = held(); - const previous = states.get(solution); - states.delete(solution); - const line = `solution registration: "${oneLine(solution)}" removed its registration (revision ${revision})${ - previous?.ok ? `, held ${after(previous.beats)}` : "" - }`; - console.info(line); - return line; -} diff --git a/module/services/frontend/code/src/solutions/registry.ts b/module/services/frontend/code/src/solutions/registry.ts index 73637edeb..00e0629ae 100644 --- a/module/services/frontend/code/src/solutions/registry.ts +++ b/module/services/frontend/code/src/solutions/registry.ts @@ -1,39 +1,33 @@ import "server-only"; +import { checkRuntimeCompatibility } from "@/solutions/compatibility"; + import { assertDataGraph, type DataGraph } from "@codefly/saas-plugin-manifest"; import type { DeclaredSource } from "@codefly-dev/saas-ui/solution"; import { getEndpoints, getWorkspaceSecret } from "codefly"; -import { isStandingConditionMilestone } from "@/solutions/registration-log"; - /** - * Runtime solution registry (generic host seam). - * - * A "solution" is an independently deployed module the host has NO build-time - * knowledge of. Solutions self-register at startup by POSTing their manifest to - * the host (see app/api/solutions/register). Nothing in this file — or anywhere - * in the saas module — names a specific solution. - * - * Registrations are NOT stored in this process. They live in one durable, - * versioned record per solution (issue #534), which the auth-gateway brokers: - * this frontend has no route to the accounts internal listener, and the gateway - * already brokers that listener for module registration. What lives here is a - * short-lived cache of the snapshot, rebuilt on demand, so a restart recovers - * every registration and two replicas serve the same set. - * - * The host only ever renders a solution whose record is ACTIVE — both halves - * registered, leases live, contract versions agreeing. A solution that - * registered its page but not its backend is durable and visible to an - * operator, and deliberately absent from the navigation: it is exactly the - * disagreement this registry exists to prevent. + * Read-only projection of the host's declared solution registry. The gateway + * brokers the durable accounts record; this process caches only its snapshot. + * Only active records with compatible frontend and backend halves are rendered. */ export interface SolutionManifest { id: string; /** - * Major of the registration manifest wire shape this solution was built - * against. Defaulted rather than required, so an existing registrant keeps - * working; see `checkRuntimeCompatibility`. + * The immutable solution target the host declared this record under — the + * key a per-viewer projection joins entitlements against. + * + * Stamped from the registry snapshot, NOT parsed from the solution's own + * manifest: the manifest is a document the solution wrote, and a solution + * naming its own target could claim the identity an organisation consented + * to for something else. Empty for a record nothing declared, which joins to + * no entitlement. + */ + targetId: string; + /** + * Major of the manifest wire shape this solution was built against. + * Validated by `checkRuntimeCompatibility` before the read projection renders it. */ schemaVersion: number; nav: { title: string; path: string; order?: number }; @@ -120,8 +114,21 @@ export interface SolutionSurface { events?: string[]; } -/** The public navigation projection (see navProjection). */ -export type SolutionNav = Pick; +/** The per-viewer navigation projection (see navProjection in projections.ts). */ +export type SolutionNav = Pick & { + /** + * False when the solution is installed and granted but its installation is + * not healthy right now — an offboarded owner of record, a revoked or disabled + * agent, a standing grant that lapsed. + * + * Such a solution stays IN the projection. The organization did install it and + * the viewer was granted it, so dropping it would send someone looking for a + * grant that already exists; what must not happen is routing it as though it + * were serving. A consumer renders it disabled and says why it cannot be + * opened. + */ + available: boolean; +}; /** The internal detail projection (see detailProjection). */ export type SolutionDetail = Omit< @@ -129,12 +136,14 @@ export type SolutionDetail = Omit< "dashboard" | "surfaces" | "sources" >; -/** The per-client surface projection (see surfacesProjection). */ +/** The per-client surface projection (see surfacesProjection in projections.ts). */ export interface SolutionClientSurfaces { id: string; title: string; /** Origin a surface's `module` path is resolved against. */ origin: string; + /** See SolutionNav.available — the same distinction, for a non-host client. */ + available: boolean; surfaces: SolutionSurface[]; } @@ -154,13 +163,21 @@ export function isClientKind(value: string): boolean { return SAFE_SLUG.test(value); } +/** + * Whether a value is shaped like a registered solution id — the rule parseManifest + * admits an id by, and the gateway's own. A value that fails it can never name a + * registration, however it was spelled elsewhere. + */ +export function isSolutionId(value: string): boolean { + return SAFE_SLUG.test(value); +} + /** * A nav path is rendered directly as an in the sidebar and home * cards, and a surface module is fetched by a client against the solution's own * origin. Both must be absolute, path-only values so a manifest can never turn * one into an open redirect, a cross-origin fetch, or a `javascript:`/`data:` - * URI. Registration is authenticated (see the register route), but the host - * still refuses to store an unsafe value. + * URI. The host refuses unsafe values when reading the declared manifest. */ function isSafeAbsolutePath(path: string): boolean { if (!path.startsWith("/") || path.startsWith("//")) { @@ -255,34 +272,6 @@ export function browserManifestUrl( */ const SNAPSHOT_TTL_MS = 5_000; -/** - * The oldest a snapshot may be and still be served while a refetch is failing. - * - * A blip must not empty the navigation, so a failed refetch keeps the previous - * snapshot — but only up to the point where the gateway would already have - * dropped every record in it. The gateway re-derives liveness from each - * registration's lease, so once a snapshot is older than that lease nothing in - * it is provably still registered: serving it renders pages whose backend the - * gateway has already stopped routing, which is the page/backend disagreement - * this registry exists to prevent. Past the lease this replica reports - * "unavailable" rather than guessing. - * - * The window belongs to the gateway — it is the lease it grants registrants — - * and now travels with every snapshot as `leaseSeconds`. This literal is only - * the fallback for a rolling upgrade in which a newer frontend reads an older - * gateway; it is deliberately not a second source of truth, because a mirrored - * copy goes silently wrong the moment the gateway's lease changes. - */ -const FALLBACK_SNAPSHOT_MAX_AGE_MS = 120_000; - -/** - * A reported lease longer than this is a misreport, not a policy. The ceiling - * is a safety bound, so it must not be settable to "effectively never" by the - * far side: `{"leaseSeconds": 1e999}` parses to Infinity, and an unchecked - * `> 0` test would accept it and silently restore unbounded staleness. - */ -const MAX_REPORTED_LEASE_MS = 3_600_000; - /** * How long the gateway gets to answer a snapshot read. It serves this from its * own in-memory cache, so it is fast or it is wedged. Matches the bound @@ -290,22 +279,11 @@ const MAX_REPORTED_LEASE_MS = 3_600_000; */ const REGISTRY_READ_TIMEOUT_MS = 2_000; -/** - * How long a registration write gets. Longer than the read because the gateway - * brokers it on to accounts under its own 10s budget and may retry once; the - * point is that it is finite, not that it is tight. - */ -const REGISTRY_WRITE_TIMEOUT_MS = 30_000; - interface RegistrySnapshot { revision: number; solutions: SolutionManifest[]; byId: Map; expiresAt: number; - /** When the gateway last answered; the staleness ceiling is measured from here. */ - fetchedAt: number; - /** The gateway's lease window: how long this may outlive a failed refetch. */ - maxAgeMs: number; } /** @@ -318,7 +296,7 @@ export type SolutionRegistryFailure = "unavailable"; // Next's dev server evaluates route handlers and pages in separate module // graphs, so a plain module-level variable is NOT shared between the -// registration endpoint and the solution page. Anchor the cache on globalThis +// navigation endpoint and the solution page. Anchor the cache on globalThis // so every module graph in this process shares one snapshot and one in-flight // fetch. const globalForRegistry = globalThis as typeof globalThis & { @@ -328,8 +306,6 @@ const globalForRegistry = globalThis as typeof globalThis & { }; const GATEWAY_REGISTRY_PATH = "/solutions/_registry"; -const GATEWAY_FRONTEND_REGISTER_PATH = "/solutions/_frontend"; -const GATEWAY_REGISTER_PATH = "/solutions/_register"; const INTERNAL_TOKEN_HEADER = "X-Codefly-Internal-Token"; /** @@ -363,6 +339,14 @@ interface GatewayRegistryEntry { id?: unknown; status?: unknown; manifest?: unknown; + /** + * The immutable solution target this record is declared under, from the + * gateway's projection. It is the HOST's fact, never the solution's: it is + * read from the record's declaration here and deliberately not from the + * manifest the solution itself registered, because a solution must not be + * able to name the identity an organisation consented to. + */ + targetId?: unknown; } /** @@ -371,44 +355,14 @@ interface GatewayRegistryEntry { * boundary since it was validated, and re-validating is cheaper than trusting * that a stored blob is still well-formed. */ -// Whether the fallback has already been reported. A dropped field would -// otherwise degrade in complete silence, which is how the mirrored constant -// this replaced went wrong in the first place. -let reportedMissingLease = false; - -/** - * The staleness ceiling this snapshot carries, from the lease the gateway says - * it grants. Clamped and finite-checked: the far side supplies it, so it is - * input, not configuration. - */ -function reportedMaxAgeMs(leaseSeconds: unknown): number { - if ( - typeof leaseSeconds === "number" && - Number.isFinite(leaseSeconds) && - leaseSeconds > 0 - ) { - return Math.min(leaseSeconds * 1_000, MAX_REPORTED_LEASE_MS); - } - if (!reportedMissingLease) { - reportedMissingLease = true; - console.error( - "solution registry: snapshot carried no usable leaseSeconds; " + - "bounding staleness by the local fallback instead", - ); - } - return FALLBACK_SNAPSHOT_MAX_AGE_MS; -} - function manifestsFromSnapshot(payload: unknown): { revision: number; - maxAgeMs: number; solutions: SolutionManifest[]; } | null { if (typeof payload !== "object" || payload === null) return null; - const { revision, solutions, leaseSeconds } = payload as { + const { revision, solutions } = payload as { revision?: unknown; solutions?: unknown; - leaseSeconds?: unknown; }; if (!Array.isArray(solutions)) return null; const manifests: SolutionManifest[] = []; @@ -428,12 +382,23 @@ function manifestsFromSnapshot(payload: unknown): { ); continue; } - manifests.push(parsed); + if (!checkRuntimeCompatibility(parsed).compatible) { + console.error(`solution registry: incompatible runtime for ${String(entry.id)}`); + continue; + } + // The target is stamped from the record, overwriting anything the + // solution's own manifest carried in that field. parseManifest already + // rejects unknown keys it does not model, but stamping unconditionally + // means a future parser that let the field through could not be used to + // claim a target either. + manifests.push({ + ...parsed, + targetId: typeof entry.targetId === "string" ? entry.targetId : "", + }); } manifests.sort((a, b) => (a.nav.order ?? 0) - (b.nav.order ?? 0)); return { revision: typeof revision === "number" ? revision : 0, - maxAgeMs: reportedMaxAgeMs(leaseSeconds), solutions: manifests, }; } @@ -442,10 +407,7 @@ function manifestsFromSnapshot(payload: unknown): { * The snapshot read's standing failure, if it is failing. * * Every browser polling the navigation drives this read, so a registry outage - * printed a line per read for as long as it lasted — and the registration - * endpoint's own per-request line is switched off for that path in - * next.config.mjs, which makes this the only thing left saying the registry is - * unreachable. It has to be readable, so it is reported as a condition: the + * printed a line per read for as long as it lasted. It has to be readable, so it is reported as a condition: the * first failed read, the same failure again only at milestone counts, and the * recovery. A one-shot latch would be the other failure — quiet while the * thing is still broken. @@ -516,41 +478,18 @@ async function fetchSnapshot(): Promise { solutions: parsed.solutions, byId: new Map(parsed.solutions.map((solution) => [solution.id, solution])), expiresAt: Date.now() + SNAPSHOT_TTL_MS, - fetchedAt: Date.now(), - maxAgeMs: parsed.maxAgeMs, }; } -/** - * The current snapshot, refetched when it has aged out. Concurrent readers - * coalesce onto one fetch, and a failed refetch keeps serving the previous - * snapshot rather than emptying the navigation on a single blip — a registry - * outage must degrade, not delete. Degrading is bounded, though: past - * SNAPSHOT_MAX_AGE_MS the snapshot is dropped rather than served, because - * beyond the gateway's lease nothing in it is provably still registered. - */ +/** Coalesce concurrent reads; fail closed when an expired snapshot cannot refresh. */ async function snapshot(): Promise { const cached = globalForRegistry.__solutionSnapshot ?? null; - if ( - cached !== null && - Date.now() < cached.expiresAt && - Date.now() - cached.fetchedAt < cached.maxAgeMs - ) { - return cached; - } + if (cached !== null && Date.now() < cached.expiresAt) return cached; if (!globalForRegistry.__solutionSnapshotInFlight) { globalForRegistry.__solutionSnapshotInFlight = fetchSnapshot() .then((fresh) => { - if (fresh !== null) { - globalForRegistry.__solutionSnapshot = fresh; - return fresh; - } - const stale = globalForRegistry.__solutionSnapshot ?? null; - if (stale !== null && Date.now() - stale.fetchedAt >= stale.maxAgeMs) { - globalForRegistry.__solutionSnapshot = null; - return null; - } - return stale; + globalForRegistry.__solutionSnapshot = fresh; + return fresh; }) .finally(() => { globalForRegistry.__solutionSnapshotInFlight = null; @@ -559,151 +498,6 @@ async function snapshot(): Promise { return globalForRegistry.__solutionSnapshotInFlight; } -/** Drop the cached snapshot so the next read reflects a write immediately. */ -function invalidateSnapshot(): void { - globalForRegistry.__solutionSnapshot = null; -} - -/** - * The outcome of a registration write. `conflict` is the registry refusing a - * stale or resurrecting write — the caller must re-read and retry, not retry - * blindly. `rejected` is the registry refusing the registration itself as - * inadmissible — today, audit event types its dashboard declares that the - * audit registry will not admit, such as a namespace another solution owns or a - * field an earlier declaration admitted and this one drops. No retry of the - * same manifest can succeed, so it must not read as an outage. - */ -export type SolutionWriteResult = - | { ok: true; revision: number; status: string } - | { ok: false; reason: "unavailable" | "conflict" | "forbidden" } - | { ok: false; reason: "rejected"; detail?: string }; - -async function writeToGateway( - path: string, - init: RequestInit, - // The registrant's own signed, solution-bound credential. The gateway - // verifies it again and takes the record's publisher from it, so the write - // carries the proof rather than this host vouching for a caller it cannot - // name. - credential?: string, -): Promise { - const origin = gatewayOrigin(); - const token = internalToken(); - if (!origin || !token) { - console.error( - "solution registry: gateway endpoint or internal token unresolved", - ); - return { ok: false, reason: "unavailable" }; - } - let response: Response; - try { - response = await fetch(`${origin}${path}`, { - ...init, - headers: { - ...(init.headers ?? {}), - [INTERNAL_TOKEN_HEADER]: token, - ...(credential - ? { "X-Codefly-Solution-Registration": credential } - : {}), - "content-type": "application/json", - }, - cache: "no-store", - signal: AbortSignal.timeout(REGISTRY_WRITE_TIMEOUT_MS), - }); - } catch (err) { - console.error("solution registry: gateway unreachable", err); - return { ok: false, reason: "unavailable" }; - } - if (response.status === 409) return { ok: false, reason: "conflict" }; - if (response.status === 403) return { ok: false, reason: "forbidden" }; - // The gateway answers 422 `registration_rejected` only when accounts - // attached the structured declaration-rejection reason; the error code is - // the signal, never the prose. Any other refusal — a 400 among them — keeps - // the outage mapping below. - if (response.status === 422) { - const refusal = (await response.json().catch(() => ({}))) as { - error?: unknown; - detail?: unknown; - }; - if (refusal.error === "registration_rejected") { - console.error( - "solution registry: the registry will not admit the declared audit event types", - ); - return { - ok: false, - reason: "rejected", - ...(typeof refusal.detail === "string" - ? { detail: refusal.detail } - : {}), - }; - } - } - if (!response.ok) { - console.error(`solution registry: write answered ${response.status}`); - return { ok: false, reason: "unavailable" }; - } - invalidateSnapshot(); - const body = (await response.json().catch(() => ({}))) as { - revision?: unknown; - status?: unknown; - }; - return { - ok: true, - revision: typeof body.revision === "number" ? body.revision : 0, - status: typeof body.status === "string" ? body.status : "unknown", - }; -} - -/** - * Register the frontend half of a solution. - * - * The manifest travels as the JSON text this host validated. The registry - * stores it in a jsonb column, so it does NOT survive as those bytes: Postgres - * re-serializes it on read with its keys reordered, a space after every colon - * and its numbers rendered as numerics. Byte stability is therefore exactly - * what a re-registration cannot be recognised by — assuming it was is what made - * every heartbeat advance the revision and churn every replica's cache. What - * recognises a renewal is JSON-value equality, in the registry's own - * sameManifest (accounts, pkg/business/solution_registry.go). When the manifest - * changes, the registry reads the one part of it that is also a registration of - * its own: the audit event types the dashboard graph declares (events carrying - * `fields`), which it admits into the audit catalog in the same write, refusing - * the whole write when it will not. - */ -export function registerSolution( - manifest: SolutionManifest, - options: { reactivate?: boolean; credential?: string } = {}, -): Promise { - return writeToGateway( - GATEWAY_FRONTEND_REGISTER_PATH, - { - method: "POST", - body: JSON.stringify({ - id: manifest.id, - manifest: JSON.stringify(manifest), - reactivate: options.reactivate === true, - }), - }, - options.credential, - ); -} - -/** - * Deregister a solution outright. Both halves go away together and a tombstone - * remains, so a retiring deployment's delayed heartbeat cannot recreate what an - * operator removed. - */ -export function unregisterSolution( - id: string, - credential?: string, -): Promise { - return writeToGateway( - `${GATEWAY_REGISTER_PATH}?id=${encodeURIComponent(id)}`, - { method: "DELETE" }, - credential, - ); -} - /** * Every solution the host should render, ordered for the navigation. Returns * the failure marker rather than an empty list when no snapshot can be @@ -716,6 +510,25 @@ export async function loadSolutions(): Promise< return current === null ? "unavailable" : current.solutions; } +/** + * The registered set together with the revision it carries — what a caller that + * caches a projection needs, since the registered set is one of the inputs that + * projection is a function of. + * + * It is a separate reader rather than a widened `loadSolutions` so the callers + * that only need the list keep the narrower return, and so the revision is + * obtained from the SAME snapshot the manifests came from. Reading the list and + * then asking for a revision separately could pair manifests with a revision from + * a later refetch, and the cache key would then claim a set it did not describe. + */ +export async function loadSolutionsWithRevision(): Promise< + { revision: number; solutions: SolutionManifest[] } | SolutionRegistryFailure +> { + const current = await snapshot(); + if (current === null) return "unavailable"; + return { revision: current.revision, solutions: current.solutions }; +} + /** One solution by id, or why it could not be resolved. */ export async function findSolution( id: string, @@ -725,79 +538,6 @@ export async function findSolution( return current.byId.get(id) ?? null; } -/** - * What every signed-in browser may read: the id and the nav entry the Solutions - * menu renders. A manifest also carries deployment topology — the origin the - * solution's code is served from, the backend service that fronts it, and its - * dashboard declaration — which no browser needs to render a link, so the - * public listing projects it away rather than shipping it to every poll. - */ -export function navProjection(manifest: SolutionManifest): SolutionNav { - return { id: manifest.id, nav: { ...manifest.nav } }; -} - -/** - * What one kind of client may read: the surfaces declared for that kind, named - * by the solution offering them. A client asks for its own kind and gets - * exactly what applies to it, so learning what is on offer no longer means - * shipping a table of who offers what inside every client. - * - * Solutions are registered deployment-wide, not per tenant, so every caller - * sees the same set today. When per-tenant enablement exists it narrows this - * projection, and every client inherits the narrowing without changing. - * - * Returns null when this solution declares nothing for that client, which is - * how the listing leaves it out rather than listing it empty. - */ -export function surfacesProjection( - manifest: SolutionManifest, - client: string, - hostOrigin?: string, -): SolutionClientSurfaces | null { - const surfaces = (manifest.surfaces ?? []).filter( - (surface) => surface.client === client, - ); - if (surfaces.length === 0) { - return null; - } - // A solution served through this host has no origin of its own a client can - // reach: its modules are paths on its backend, which this host serves under - // the solution's proxy base. Without the host's own origin there is nothing - // to resolve them against, so the solution is left out rather than answered - // with an address that cannot work. - const servedByHost = manifest.frontend.manifestUrl.startsWith("/"); - if (servedByHost && !hostOrigin) { - return null; - } - const modulePath = (module: string) => - servedByHost ? `${solutionProxyBase(manifest.id)}${module}` : module; - return { - id: manifest.id, - title: manifest.nav.title, - // A declared module is a path on the solution's origin, so without the - // origin no caller can fetch one. The origin is not withheld topology - // here the way the manifest path is: the client fetches the module from - // it directly, and every signed-in document already carries it in the - // CSP that admits the same origin's code. - origin: servedByHost - ? (hostOrigin as string) - : new URL(manifest.frontend.manifestUrl).origin, - // A shallow copy would share `applies.tagged` and `events` with the - // cached snapshot, which outlives this response and is read by every - // later caller — including other client kinds, which read the same - // manifest objects. - surfaces: surfaces.map((surface) => ({ - ...surface, - module: modulePath(surface.module), - applies: - typeof surface.applies === "object" - ? { tagged: [...surface.applies.tagged] } - : surface.applies, - events: surface.events === undefined ? undefined : [...surface.events], - })), - }; -} - /** * What a caller holding the cluster-internal token may read: everything needed * to resolve the remote and its backend. The dashboard graph is left out — it @@ -807,6 +547,7 @@ export function surfacesProjection( export function detailProjection(manifest: SolutionManifest): SolutionDetail { return { id: manifest.id, + targetId: manifest.targetId, schemaVersion: manifest.schemaVersion, nav: { ...manifest.nav }, frontend: { ...manifest.frontend }, @@ -1056,7 +797,7 @@ function parseDeclaredSources( return sources; } -/** Minimal structural validation of a self-registration payload. */ +/** Structural validation of a declared solution manifest. */ export function parseManifest(value: unknown): SolutionManifest | null { if (typeof value !== "object" || value === null) { return null; @@ -1140,6 +881,12 @@ export function parseManifest(value: unknown): SolutionManifest | null { } return { id: candidate.id, + // parseManifest reads a document the SOLUTION wrote, so it never carries + // a target: the host stamps it from the registry record in + // manifestsFromSnapshot. Empty here is therefore correct and not a + // missing field — a manifest that reached a projection without being + // stamped joins to no entitlement, which is the fail-closed answer. + targetId: "", schemaVersion: schemaVersion as number, nav: { title: nav.title, @@ -1166,3 +913,10 @@ export function parseManifest(value: unknown): SolutionManifest | null { sources, }; } + +function isStandingConditionMilestone(occurrences: number): boolean { + if (occurrences < 10) return false; + let threshold = 10; + while (threshold < occurrences) threshold *= 10; + return threshold === occurrences; +} diff --git a/module/services/frontend/service.codefly.yaml b/module/services/frontend/service.codefly.yaml index 433f00361..b5f8e382d 100644 --- a/module/services/frontend/service.codefly.yaml +++ b/module/services/frontend/service.codefly.yaml @@ -22,6 +22,9 @@ workspace-configuration-dependencies: endpoints: - name: http visibility: public + # Required at core v0.14.0; both values are decisions, recorded in + # ../../deployment/AGENTS.md under "`exposure` on a public endpoint". + exposure: public spec: build-args: FRONTEND_SKIN_RUNTIME: "1" @@ -43,16 +46,9 @@ spec: http: 3000 public-egress-ports: - 443 - # Solution self-registration mutates what the nav renders and what the - # solution page loads as a Module-Federation remote, on the same port that - # serves every browser-facing page. Only a path match separates the two, - # and only in the mesh's L7 layer; /api/internal/solutions is read by the - # proxy over pod-local loopback, which the mesh never captures. + # Internal topology reads share the public HTTP port and require a + # path-scoped mesh policy in addition to the application credential. internal-http-routes: - path: /api/internal/solutions methods: - GET - - path: /api/solutions/register - methods: - - DELETE - - POST diff --git a/module/services/marketing/service.codefly.yaml b/module/services/marketing/service.codefly.yaml index 99269f77f..eb09e64a6 100644 --- a/module/services/marketing/service.codefly.yaml +++ b/module/services/marketing/service.codefly.yaml @@ -12,6 +12,9 @@ agent: endpoints: - name: http visibility: public + # Required at core v0.14.0; both values are decisions, recorded in + # ../../deployment/AGENTS.md under "`exposure` on a public endpoint". + exposure: public spec: # Declare the argument at its source so regeneration preserves the opt-in # production-readiness gate; an empty default does not force it off. diff --git a/module/services/policy-log/README.md b/module/services/policy-log/README.md new file mode 100644 index 000000000..24932a5db --- /dev/null +++ b/module/services/policy-log/README.md @@ -0,0 +1,349 @@ +# Policy log + +`policy-log` is the independent witness for authority changes. Its contract is +append → signed receipt → commit-with-receipt in the caller. It assigns the +global sequence, owns the signing key, and records the committed prefix in its +own object store. A warehouse insert is only a mirror of that evidence. + +**Implementation status:** the witness, GCS adapter, fixed-path key loader, +warehouse writer, transport-independent service, protobuf projection, gRPC +server and runnable binary are implemented and tested. The service is declared +in the module inventory and has its own private endpoint, service account, and +deployment declaration. + +What is still missing is **delivery**, not code: the `policy-log` configuration +group ships no local default, so no environment here can boot this service, and +the signing key's fixed path exists on no development machine. Both refusals are +deliberate — see [Transport](#transport) for what that means for `codefly run`. +No generated code was hand-written: the required no-change Codefly +proto-generation check ran and came back byte-identical on two sibling services, +and this service's own tree regenerates reproducibly. The host's client is +separate work; no host Go code is changed here. Nothing in this directory has +been deployed or booted, and the OpenAPI projection does not exist because the +endpoint is private gRPC and reaches no gateway. + +## Trust domain + +The object store, signing key, and warehouse writer belong to the warehouse's +trust domain, independent of the database whose authority they witness. A +database restore must not restore the witness. Accounts must have no object +write, signing-key read, warehouse writer, or witness administration capability. +The witness holds no accounts database credential or dependency. + +Delivery must provision a dedicated bucket, workload identity, private signing +key, and warehouse ingestion credential. Bucket IAM must confine the workload +to that bucket and forbid deleting or replacing `entries/` objects; enforce the +immutable prefix with retention and IAM, in addition to application checks. +The head's write authority is scoped to `head.json`. Bucket lifecycle deletion, +rewriting an existing sequence, or restoring the head from a database backup +would defeat the trust boundary and are not recovery procedures. Provision +versioning and deletion protection outside the workload's administrative reach. + +The runtime exposes no delete, reset, unconditional overwrite, alternate key +path, insecure warehouse transport, or emulator option. The GCS constructor +refuses a storage emulator setting. The warehouse uses its own mandatory bearer +credential over HTTPS and refuses redirects. + +The listener is private and must stay private. Composition must admit only the +host's declared client through workload-authenticated mesh policy, resolve its +endpoint through Codefly, and deliver credentials exclusively to that client and +this service. A shared credential available to arbitrary composed workloads is +not sufficient authority to append. No caller supplies sequence numbers or +receipt timestamps. `TestTheWitnessListenerStaysPrivate` holds the manifest to +that: a second endpoint, a declared `visibility`, a Connect listener, a REST api +or any service dependency fails it. + +[service.codefly.yaml](service.codefly.yaml) uses the same `go-grpc` agent and +Codefly-allocated endpoint convention as the other Go services. Its `policy-log` +configuration group is delivered by composition; no default bucket, warehouse +credential, endpoint, or development bypass ships here. The fixed signing key +is a file projection, never a group setting. The service has no accounts or +store dependency and is not a public or module-visible interface export. + +## Contract + +The authored Go contract is [code/witness/contract.go](code/witness/contract.go). +The transport adapter exposes these four unary operations through +[code/service/service.go](code/service/service.go): + +| Operation | Request | Response | +| --- | --- | --- | +| `Append` | `entry` | `Receipt` | +| `Cursor` | empty | signed `{head_seq, chain_hash, issued_at, signature}` | +| `Read` | `from_seq`, `limit` | ordered entries with their original receipts | +| `Keys` | empty | `{algorithm, key_id, public_key}` | + +An entry consists of `operation_id`, `kind`, `subject`, `tenant`, +`narrowing_hash` (exactly 32 SHA-256 bytes), and `requested_at`. Identifiers are +opaque UTF-8 strings, with no control characters: operation, subject, and tenant +are at most 1024 bytes, kind at most 128. Tenant may be empty for a global +operation; it grants no authority. The other identifiers and timestamp are +required. The witness stores the hash, not the narrowing payload. Reconciliation +can identify an operation and verify its bytes; the host must retain or recover +the canonical operation bytes to apply it. A hash cannot reconstruct them. + +`operation_id` identifies one logical operation for the lifetime of the log. +All entry fields, including `requested_at`, must stay identical on retry. The +same entry hash returns the exact original receipt, even after a restart or +later appends. Reusing the ID with a different hash is refused. `requested_at` +is the caller's logical timestamp, not a timeout or ordering authority; old +operations can be retried without changing their identity. + +A receipt contains `seq` (nonzero uint64), `entry_hash`, `prev_hash`, +`chain_hash`, `issued_at`, and a 64-byte Ed25519 `signature`. All hashes are +32-byte values. The first sequence is 1; its previous hash is 32 zero bytes. +`issued_at` comes from the witness. Sequence, rather than clock time, orders +operations. `Read` starts at an **inclusive** sequence, at least 1, with a limit +from 1 through 500. Reading at `head_seq + 1` returns an empty page; asking +beyond it refuses, so an ahead-of-log reconciler cannot mistake rollback for +inactivity. A host API using an exclusive cursor maps it to `cursor + 1`. + +### Canonical bytes + +Signatures never depend on JSON serialization or protobuf field ordering. +`U64` and `U32` are unsigned big-endian integers. `S(text)` is its UTF-8 byte +length as U32 followed by those bytes. `T(time)` is Unix seconds as the +two's-complement bits of an int64, followed by nanoseconds as U32. Hashes are +raw bytes. Each domain below ends in one NUL byte, not the two characters +backslash and zero. + +```text +entry_hash = SHA256( + "policy-log.entry.v1\0" || + S(operation_id) || S(kind) || S(subject) || S(tenant) || + narrowing_hash || T(requested_at)) + +chain_hash = SHA256( + "policy-log.chain.v1\0" || U64(seq) || prev_hash || entry_hash) + +receipt_signature = Ed25519.Sign(private_key, + "policy-log.receipt.v1\0" || U64(seq) || + entry_hash || prev_hash || chain_hash || T(issued_at)) + +cursor_signature = Ed25519.Sign(private_key, + "policy-log.cursor.v1\0" || U64(head_seq) || chain_hash || T(issued_at)) +``` + +Clients verify signatures under their independently pinned key, compare the +receipt's entry hash to their requested bytes, verify every chain link, and +persist the highest verified sequence and chain hash. A cursor is a signed +observation, not a receipt or a reset instruction. Never lower a persisted +cursor, accept a different hash at the same sequence, or substitute a fresh +network key for a pinned one. Signature verification alone cannot distinguish +a historical signed cursor from a current one; the host must also enforce its +freshness and reachability policy. + +### Public key delivery + +[identity.SigningKeyPath](code/identity/signing_key.go) is the single literal +fixed path. The loader takes no path argument, consults no environment override, +and never generates a key. Delivery provides exactly one PEM-encoded PKCS#8 +Ed25519 private key, a regular file of at most 4096 bytes with no group or world +access, readable by the service UID. A missing, malformed, exposed, or wrong +algorithm key refuses startup. Projected-file symlinks are acceptable when the +opened target satisfies those checks. + +The host receives `policy_log_public_key.json` under its existing trust-anchor +directory. Composition delivers that public document; this service must never +write the host's trust anchor. The schema matches `Keys`: + +```json +{ + "algorithm": "Ed25519", + "key_id": "lowercase SHA-256 hex of the raw public key", + "public_key": "standard base64 of the 32-byte public key" +} +``` + +`Keys` publishes the public half, never private material. It does not authorize +trust-anchor replacement. Key rotation is deliberately not implemented: boot +with a different key refuses when the existing history fails verification. + +## Transport + +[proto/saas/policylog/v1/witness.proto](proto/saas/policylog/v1/witness.proto) +is the only wire projection of the Go contract, and +[code/server/server.go](code/server/server.go) is the only adapter. The server +owns the wire shape and the error translation and no policy of its own: +validation, sequencing, signing and the five-second budget stay behind the +service boundary. Regenerate with the command in +[proto/README.md](proto/README.md); never by hand and never with `buf generate`. + +The proto carries every contract field losslessly, which is a requirement rather +than a convenience: the digests travel as raw bytes and the timestamps as +seconds and nanoseconds, so a reader can recompute `entry_hash` from the entry +it received, and a retry of one operation reproduces the same hash. The one +thing the projection has to add is a **refusal of an absent `requested_at`**: +`AsTime` of a missing Timestamp is 1970-01-01, which `Entry.Validate` accepts, +so without that check the witness would sign a hash over a timestamp the caller +never sent and no retry could reproduce it. + +The five taxonomy errors are not interchangeable and each gets its own code. A +caller told the wrong one either gives up on a state that was about to clear or +retries one that never will: + +| Contract error | Code | What the caller must do | +| --- | --- | --- | +| `ErrInvalid` | `InvalidArgument` | Never retry these bytes; they cannot be made to succeed | +| `ErrConflict` | `AlreadyExists` | Permanent: the operation id names different bytes. Do not retry, and do not rename the operation to get past it | +| `ErrPending` | `FailedPrecondition` | Do not retry this operation. Only an explicit retry of the operation holding the next sequence can clear it — and it does clear, which is why this is not `AlreadyExists` | +| `ErrRollback` | `Aborted` | A sequencer check failed: the head regressed, changed a known hash, or the caller is ahead of the log. Stop serving the authority still held locally | +| `ErrIntegrity` | `DataLoss` | The log's own stored evidence does not verify. Unrecoverable, and distinct from a regression | +| cancellation / deadline | `Canceled` / `DeadlineExceeded` | Genuinely ambiguous: a storage request in flight can have committed. Retry the identical operation and let the head answer | +| anything else | `Unavailable` | No sequence was assigned and no receipt exists; retry the identical operation | + +`Unavailable` deliberately carries a fixed message. A store error can name a +bucket and an endpoint, the caller's correct behaviour does not depend on it, +and the detail goes to this service's own failure reporter instead. + +[code/main.go](code/main.go) is the binary. Every construction happens **before** +`net.Listen`, in this order, and any failure is fatal: resolve the whole +configuration group, read the Codefly-allocated port, load the fixed-path +signing key, open the bucket, build the warehouse writer, start the mirror +worker, then `witness.Open`, which verifies the entire committed chain under +that key. A witness that accepted an append it could not sign, store, or verify +the history of would be worse than one that is down, because the caller would +commit against it. + +`POLICY_LOG_BUCKET`, `POLICY_LOG_WAREHOUSE_ENDPOINT` and +`POLICY_LOG_WAREHOUSE_TOKEN` come from the `policy-log` configuration group and +from nowhere else. There is no process-environment fallback, no default bucket +and no development bypass, so an ambient value cannot make this service witness +somewhere nobody provisioned — and the refusal names the missing keys, never the +delivered values, because one of the three is a credential. The consequence is +that **`codefly run` cannot start this service on a workstation**: the group +ships no local default and the fixed key path exists on no development machine. +That is the posture the trust domain requires, not an oversight; a local harness +for it would be a development bypass by another name. + +The listener registers the four witness operations and nothing else — no +reflection service and no health service — because its surface is the whole of +what a composed workload could reach if mesh policy ever leaked. + +## Storage and failures + +[objectstore.Store](code/objectstore/store.go) has two operations: a strongly +consistent read returning bytes and generation together, and a conditional +write. The production [GCS adapter](code/objectstore/gcs/gcs.go) uses the +official client, JSON reads, CRC32C uploads, no resumable upload, and +`RetryNever`. Tests use the [in-memory fake](code/objectstore/memory/memory.go). + +Infrastructure initializes `head.json` once with the following JSON. The runtime +refuses an absent head instead of treating a deletion as an empty log: + +```json +{"seq":0,"chain_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} +``` + +For each append, the witness reads the head generation and verifies any new +committed prefix. It prepares a receipt candidate, creates +`entries/00000000000000000001.json` (and succeeding fixed-width sequence names) +with `ifGenerationMatch=0`, then advances `head.json` using +`ifGenerationMatch=`. Only a successful head write makes +the entry visible to `Read` and allows `Append` to return its receipt. A +candidate's signature in the private object store is not yet an issued receipt. + +Every conditional write has one attempt. A lost precondition is a refusal with +no receipt, never an internal retry with a newly minted sequence. Two appenders +over a prepared identical entry may race the head; exactly one wins. Two +different entries racing a sequence cannot overwrite one another. + +| Failure | Result | +| --- | --- | +| Immutable entry creation fails | No head advance and no receipt | +| Entry persists but head write fails | Entry is invisible; no receipt | +| Head precondition is lost | Refusal, no retry, no receipt | +| Head commits but response is lost | Exact retry recovers the persisted receipt | +| Warehouse mirror fails or queue fills | Committed receipt remains valid; repair mirror from `Read` | +| Head is unavailable, regresses, or changes a known hash | Append, cursor, and reads refuse | + +An uncommitted entry reserves its immutable sequence. Only an explicit retry of +that same operation and hash can finish it, using the original candidate. A +different operation refuses rather than deleting, overwriting, skipping, or +automatically committing the orphan. This sacrifices availability after an +interrupted append until its original operation is retried. It prevents a +recovery worker from performing a narrowing after its request has expired. + +The in-memory index is rebuilt from the entire committed chain on boot. It is +not a durable authority. Every operation re-reads the head, including idempotent +appends and empty reads; cached evidence never hides an unreachable store. +Within a process the cursor only advances. Across restarts, deletion protection +and the host's persisted verified cursor remain necessary: a signing key alone +cannot detect a privileged operator restoring an older valid head and prefix. + +### Five-second budget + +`Append` bounds the caller's context to five seconds, including queueing and +catching up with another appender. Earlier deadlines win. Storage receives the +same cancellation context, and cancellation is checked before each mutation +and before returning any receipt. There is no detached append goroutine, retry, +or warehouse wait on the request path. + +Cancellation is not a distributed abort. A GCS request already in flight can +have committed even when the response times out. The adapter cannot promise +that GCS itself never completes such a request after the local deadline; GCS +generation preconditions do not include a server-enforced expiration time. The +service withholds the receipt on timeout and reconciles the ambiguity through +the head on a later explicit retry/read. The fake proves that no expired write +is initiated or committed by a context-honoring store; it does not prove a +stronger remote cancellation guarantee. That distinction needs real-GCS +qualification before the transport can claim the full deadline requirement. + +## Warehouse mirror + +The [warehouse writer](code/mirror/warehouse.go) follows the repository's JSON +POST ingestion convention with a dedicated credential. The service offers an +entry only after the head commits. One worker consumes a bounded 64-entry +queue; failed and dropped mirrors are observable and recoverable from `Read`. +The mirror may contain duplicates after explicit retries and must deduplicate +by operation ID. It is best effort, is not backed by the accounts database, +and is never consulted to assign a sequence, sign a receipt, or reconstruct the +authoritative cursor. + +## Verification + +From `code/`: + +```sh +go build ./... +go vet ./... +gofmt -l . +go test -race -v ./... +``` + +From `module/services/policy-log`, after any proto change and before it: + +```sh +codefly generate proto --proto ./proto --output .. --template policy-log/proto/buf.gen.yaml +``` + +The witness tests independently encode the hashes and signatures and exercise +two appender instances over one generation-aware fake. The GCS adapter tests +intercept the official client's HTTP requests and verify its preconditions, +single-attempt failures, immutable entry names, bounded reads, and cancellation. +They do not contact GCS. Key tests refuse absent and invalid fixed-path keys; +service tests prove that a failed head write never offers an entry to the mirror +and a failed mirror cannot decide whether a receipt exists. + +The transport is tested twice over. `code/server/server_test.go` drives each arm +of the error taxonomy directly, which is the only way to reach all of them, and +asserts that two arms never share a code and that no sentinel is left unmapped. +`code/server/transport_test.go` then runs the whole shipped stack — witness, +service, projection, generated client — over a real loopback gRPC connection, so +the arms are the ones the shipped code actually raises: a receipt that verifies +under the key `Keys` publishes, a retry returning the original receipt, a reused +operation id with different bytes as `AlreadyExists`, an interrupted append as +`FailedPrecondition` until its own retry finishes it, and a read at `head + 1` +empty while `head + 2` refuses. + +Not exercised, by name: real GCS, deployed IAM and retention policy, the +container build, `codefly ci run`, `codefly run` and the Codefly service graph, +a deployed mesh policy, and a running host client. **Nothing here has been +deployed or booted** — the binary's own startup path (`codefly.Init`, the +injected port, the fixed-path key, the GCS client, `witness.Open` against a real +bucket) is covered by no test and has never executed. The signature of +`resolveSettings` is tested; its one caller's use of `WorkspaceValue` is not. + +The fixed key-path literal still requires an explicit repository naming-policy +decision: the existing exemption for the host trust anchor does not cover this +new file. No gate or allowlist was weakened here. diff --git a/module/services/policy-log/buf.gen.local.yaml b/module/services/policy-log/buf.gen.local.yaml new file mode 100644 index 000000000..f1afec55d --- /dev/null +++ b/module/services/policy-log/buf.gen.local.yaml @@ -0,0 +1,18 @@ +# Not a command to run. `module/tools/generated-pins-gate.mjs` reads the plugin +# versions it checks this service's checked-in generated Go against from here, +# so this file must keep naming the versions the proto companion image runs. +# Regenerate with the command in proto/README.md. +version: v2 +clean: true +managed: + enabled: true + override: + - file_option: go_package_prefix + value: policy-log/pkg/gen +plugins: + - local: [go, run, google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11] + out: code/pkg/gen + opt: paths=source_relative + - local: [go, run, google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1] + out: code/pkg/gen + opt: paths=source_relative diff --git a/module/services/policy-log/code/go.mod b/module/services/policy-log/code/go.mod new file mode 100644 index 000000000..6638091c4 --- /dev/null +++ b/module/services/policy-log/code/go.mod @@ -0,0 +1,95 @@ +module policy-log + +go 1.27.0 + +require ( + cloud.google.com/go/storage v1.65.0 + github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e + google.golang.org/api v0.299.0 + google.golang.org/grpc v1.84.0 + google.golang.org/protobuf v1.36.12 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/go/protovalidate v1.4.0 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.23.3 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.1 // indirect + cloud.google.com/go/iam v1.13.0 // indirect + cloud.google.com/go/logging v1.19.1 // indirect + cloud.google.com/go/monitoring v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/codefly-dev/core v0.9.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v1.0.0 // indirect + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.1 // indirect + github.com/go-openapi/jsonreference v1.0.2 // indirect + github.com/go-openapi/loads v0.25.3 // indirect + github.com/go-openapi/spec v1.0.1 // indirect + github.com/go-openapi/strfmt v0.27.2 // indirect + github.com/go-openapi/swag/conv v0.29.1 // indirect + github.com/go-openapi/swag/jsonutils v0.29.1 // indirect + github.com/go-openapi/swag/loading v0.29.1 // indirect + github.com/go-openapi/swag/mangling v0.29.1 // indirect + github.com/go-openapi/swag/pools v0.29.1 // indirect + github.com/go-openapi/swag/stringutils v0.29.1 // indirect + github.com/go-openapi/swag/typeutils v0.29.1 // indirect + github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gofrs/flock v0.13.1 // indirect + github.com/google/go-github/v89 v89.0.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/google/s2a-go v0.1.10 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect + github.com/googleapis/gax-go/v2 v2.24.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid/v2 v2.1.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect + github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.45.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect +) diff --git a/module/services/policy-log/code/go.sum b/module/services/policy-log/code/go.sum new file mode 100644 index 000000000..9e3c70ab4 --- /dev/null +++ b/module/services/policy-log/code/go.sum @@ -0,0 +1,310 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.23.3 h1:UMK+oBtuNGMCR/6i6mmySUItqjOazpJrbmZyhGbGBWo= +cloud.google.com/go/auth v0.23.3/go.mod h1:fClbry28fo7XkxhSeT6AQtAVAp6Jy0fW9N99PoPNPFM= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.1 h1:CTE1OWBQ0vnF5uHwdFAQJvMQ0Fi/KRcqqKTo9V0F8Ik= +cloud.google.com/go/compute/metadata v0.9.1/go.mod h1:NtnlvB6X3t4R6xSWyVX/ZWk493PCxGQlhI/iqxh4M8I= +cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U= +cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0= +cloud.google.com/go/logging v1.19.1 h1:7SsLhyTDBDrJw+Ll6Ns3I2mByqHXvJUc3rGjSlwiWgU= +cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= +cloud.google.com/go/storage v1.65.0 h1:McbFt5j+hTNx+dkFuzq7teakIKcpqGp/cJZRxMyfvAc= +cloud.google.com/go/storage v1.65.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= +github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= +github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= +github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= +github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e/go.mod h1:X0JAk/gnr6ZcjuPsM+5jvisqY4iyTC1SeVac93iWX8A= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= +github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= +github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= +github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= +github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= +github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= +github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= +github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= +github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= +github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= +github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= +github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= +github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= +github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= +github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= +github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= +github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= +github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= +github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= +github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= +github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= +github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= +github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= +github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= +github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= +github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= +github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= +github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= +github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= +github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= +github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= +github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= +github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.10 h1:EMp+aOuXN6l8cE/gjF5Bt+vyZxsUuyCWe9chDWR/+uU= +github.com/google/s2a-go v0.1.10/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= +github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.24.1 h1:AtqTN21IXMMWo99LiEVAiBfNNQmO40d8xUfZI640mc0= +github.com/googleapis/gax-go/v2 v2.24.1/go.mod h1:bWeBei0NVwaNZKb2y1HUBS7gLXIF3/Tu3pq7j8D2Tb0= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= +github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= +github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.45.0 h1:9jR0ZPRok9ryaOQ2Wx8rg5F7Aon59mxrqbVI60/vlBk= +go.opentelemetry.io/contrib/detectors/gcp v1.45.0/go.mod h1:VSme3o2fvSg5bVg0dRzyHaj4Z5EVhG+g2Fde6LKzmQA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= +golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.299.0 h1:b3K+ydSMd0kh6TQI6bJyApRQfqQX2MfSOaVkpM59mJw= +google.golang.org/api v0.299.0/go.mod h1:zlR3GVA8b2R5nv5Ij9UWe37StVB3cxDD7DBFi4ZFsHw= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/module/services/policy-log/code/identity/signing_key.go b/module/services/policy-log/code/identity/signing_key.go new file mode 100644 index 000000000..f2aadefa7 --- /dev/null +++ b/module/services/policy-log/code/identity/signing_key.go @@ -0,0 +1,59 @@ +// Package identity owns the fixed witness signing-key trust boundary. +package identity + +import ( + "bytes" + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "io/fs" + "os" +) + +const SigningKeyPath = "/etc/obin/policy-log/signing_key" + +// LoadSigningKey has no path argument and no environment override. Delivery +// mounts a PKCS#8 PEM Ed25519 private key at the fixed path, mode 0400 or 0600, +// readable by the service uid. No key is generated at boot. +func LoadSigningKey() (ed25519.PrivateKey, error) { + return loadSigningKey(func(path string) (fs.File, error) { return os.Open(path) }) +} + +func loadSigningKey(open func(string) (fs.File, error)) (ed25519.PrivateKey, error) { + f, err := open(SigningKeyPath) + if err != nil { + return nil, fmt.Errorf("policy-log: signing key required: %w", err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 4096 { + return nil, errors.New("policy-log: signing key must be a private regular file of at most 4096 bytes") + } + body, err := io.ReadAll(io.LimitReader(f, 4097)) + if err != nil { + return nil, err + } + if len(body) > 4096 { + return nil, errors.New("policy-log: signing key too large") + } + block, rest := pem.Decode(body) + if block == nil || block.Type != "PRIVATE KEY" || len(block.Headers) != 0 || len(bytes.TrimSpace(rest)) != 0 || + !bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(pem.EncodeToMemory(block))) { + return nil, errors.New("policy-log: signing key must be exactly one PKCS#8 private key") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("policy-log: parse signing key: %w", err) + } + key, ok := parsed.(ed25519.PrivateKey) + if !ok || len(key) != ed25519.PrivateKeySize { + return nil, errors.New("policy-log: signing key is not Ed25519") + } + return key, nil +} diff --git a/module/services/policy-log/code/identity/signing_key_test.go b/module/services/policy-log/code/identity/signing_key_test.go new file mode 100644 index 000000000..5f294426c --- /dev/null +++ b/module/services/policy-log/code/identity/signing_key_test.go @@ -0,0 +1,68 @@ +package identity + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "io/fs" + "testing" + "testing/fstest" +) + +func TestMissingSigningKeyRefusesBoot(t *testing.T) { + t.Setenv("POLICY_LOG_SIGNING_KEY", "ignored") + t.Setenv("POLICY_LOG_SIGNING_KEY_PATH", "/tmp/example-key") + var requested string + key, err := loadSigningKey(func(path string) (fs.File, error) { requested = path; return nil, fs.ErrNotExist }) + if len(key) != 0 || !errors.Is(err, fs.ErrNotExist) || requested != SigningKeyPath { + t.Fatalf("missing fixed key did not refuse: requested=%q key length=%d err=%v", requested, len(key), err) + } +} + +func TestSigningKeyAcceptsOnlyPrivateEd25519PKCS8(t *testing.T) { + want := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, 32)) + der, err := x509.MarshalPKCS8PrivateKey(want) + if err != nil { + t.Fatal(err) + } + valid := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + otherDER, err := x509.MarshalPKCS8PrivateKey(other) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + body []byte + mode fs.FileMode + valid bool + }{ + {"private", valid, 0400, true}, {"owner_write", valid, 0600, true}, + {"public", valid, 0444, false}, {"group", valid, 0440, false}, + {"empty", nil, 0400, false}, {"invalid", []byte("invalid"), 0400, false}, + {"leading_junk", append([]byte("untrusted prefix\n"), valid...), 0400, false}, + {"oversize", bytes.Repeat([]byte("x"), 4097), 0400, false}, + {"wrong_algorithm", pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: otherDER}), 0400, false}, + {"multiple_keys", append(bytes.Clone(valid), valid...), 0400, false}, + } { + t.Run(tc.name, func(t *testing.T) { + files := fstest.MapFS{"key": &fstest.MapFile{Data: tc.body, Mode: tc.mode}} + got, err := loadSigningKey(func(string) (fs.File, error) { return files.Open("key") }) + if tc.valid { + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("valid key: %v", err) + } + } else if err == nil || got != nil { + t.Fatal("invalid key accepted") + } + }) + } +} diff --git a/module/services/policy-log/code/main.go b/module/services/policy-log/code/main.go new file mode 100644 index 000000000..35a653d8c --- /dev/null +++ b/module/services/policy-log/code/main.go @@ -0,0 +1,173 @@ +// policy-log is the independent signed witness for authority changes. This +// binary wires the fixed-path signing key, the object store, the warehouse +// mirror and the transport-independent service onto the gRPC endpoint Codefly +// allocated, and refuses to start if any one of them is not provisioned. +// +// Nothing here may start a listener it cannot witness with, so every +// construction happens BEFORE net.Listen and any failure is fatal. A witness +// that accepted an append it could not sign, store or verify the history of +// would be worse than one that is down: the caller would commit against it. +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "os/signal" + "strconv" + "strings" + "syscall" + + "policy-log/identity" + "policy-log/mirror" + "policy-log/objectstore/gcs" + policylogv1 "policy-log/pkg/gen/saas/policylog/v1" + "policy-log/server" + "policy-log/service" + "policy-log/witness" + + codefly "github.com/codefly-dev/sdk-go" + "google.golang.org/grpc" +) + +// grpcEndpointName is the endpoint this service declares in +// service.codefly.yaml. It is the endpoint's NAME, which is how the port is +// looked up; the port itself is allocated by Codefly and never written here. +const grpcEndpointName = "grpc" + +// policyLogConfiguration is the workspace configuration group composition +// provisions for this service. It is the ONLY source of these settings. +const policyLogConfiguration = "policy-log" + +var settingKeys = struct{ bucket, warehouseEndpoint, warehouseToken string }{ + bucket: "POLICY_LOG_BUCKET", + warehouseEndpoint: "POLICY_LOG_WAREHOUSE_ENDPOINT", + warehouseToken: "POLICY_LOG_WAREHOUSE_TOKEN", +} + +type settings struct{ bucket, warehouseEndpoint, warehouseToken string } + +// resolveSettings reads the whole configuration from the delivered group and +// from nowhere else. +// +// There is deliberately no process-environment fallback, no default bucket and +// no development bypass. A witness that fell back to an ambient value would +// witness somewhere nobody provisioned — and it would do so by starting +// successfully, which is the one failure mode this service cannot have. Every +// missing key is named at once, so a half-provisioned group takes one restart +// to diagnose rather than three. +func resolveSettings(value func(group, key string) (string, error)) (settings, error) { + var resolved settings + var missing []string + for _, field := range []struct { + key string + into *string + }{ + {settingKeys.bucket, &resolved.bucket}, + {settingKeys.warehouseEndpoint, &resolved.warehouseEndpoint}, + {settingKeys.warehouseToken, &resolved.warehouseToken}, + } { + // A group that is absent and a key that is empty are the same thing to + // this service: nothing was provisioned. Neither value is reported back, + // because one of the three is a credential. + got, err := value(policyLogConfiguration, field.key) + if err != nil || got == "" { + missing = append(missing, field.key) + continue + } + *field.into = got + } + if len(missing) > 0 { + return settings{}, fmt.Errorf("configuration group %q delivered no %s: composition must provision the whole group before this service can witness", + policyLogConfiguration, strings.Join(missing, ", ")) + } + return resolved, nil +} + +func run(ctx context.Context, stop func()) error { + resolved, err := resolveSettings(func(group, key string) (string, error) { + return codefly.For(ctx).WorkspaceValue(group, key) + }) + if err != nil { + return err + } + port := codefly.For(ctx).WithDefaultNetwork().API(grpcEndpointName).NetworkInstance().Port + if port == 0 { + return fmt.Errorf("Codefly did not inject a port for the %q endpoint", grpcEndpointName) + } + // The fixed path takes no argument and consults no override: a key this + // process chose would witness under an identity nobody pinned. + key, err := identity.LoadSigningKey() + if err != nil { + return err + } + store, err := gcs.New(ctx, resolved.bucket) + if err != nil { + return err + } + defer func() { _ = store.Close() }() + warehouse, err := mirror.NewWriter(resolved.warehouseEndpoint, resolved.warehouseToken) + if err != nil { + return err + } + // A dropped or failed mirror is reported and repairable from Read. It never + // changes an append's outcome, so it is logged, not fatal. + worker, err := mirror.Start(ctx, warehouse, func(err error) { + log.Printf("policy-log: warehouse mirror: %v", err) + }) + if err != nil { + return err + } + // Open verifies the entire committed chain under this key. It is deliberately + // ahead of net.Listen: a witness that cannot verify its own history, or whose + // head is absent or regressed, must never reach a state where it can accept + // an append. + witnessLog, err := witness.Open(ctx, store, key) + if err != nil { + return err + } + appendOnly, err := service.New(witnessLog, worker) + if err != nil { + return err + } + handler, err := server.New(appendOnly, func(err error) { + log.Printf("policy-log: witness: %v", err) + }) + if err != nil { + return err + } + listener, err := net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(int(port)))) + if err != nil { + return err + } + // No reflection and no health service: this listener is private to the + // module and its surface is the four witness operations, nothing more. + grpcServer := grpc.NewServer() + policylogv1.RegisterWitnessServiceServer(grpcServer, handler) + go func() { + if err := grpcServer.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + log.Printf("policy-log: serve: %v", err) + stop() + } + }() + fmt.Printf("policy log witness listening on Codefly gRPC endpoint %d\n", port) + <-ctx.Done() + grpcServer.GracefulStop() + return nil +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + provider, err := codefly.Init(ctx) + if err != nil { + log.Fatalf("policy-log: %v", err) + } + ctx = provider.Inject(ctx) + defer codefly.CatchPanic(ctx) + if err := run(ctx, stop); err != nil { + log.Fatalf("policy-log: %v", err) + } +} diff --git a/module/services/policy-log/code/main_test.go b/module/services/policy-log/code/main_test.go new file mode 100644 index 000000000..3341f037e --- /dev/null +++ b/module/services/policy-log/code/main_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "errors" + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestResolveSettingsNamesEveryMissingKeyAtOnce: a half-provisioned group must +// cost one restart to diagnose, not one per key, and it must fail rather than +// start a listener that cannot witness. +func TestResolveSettingsNamesEveryMissingKeyAtOnce(t *testing.T) { + _, err := resolveSettings(func(string, string) (string, error) { return "", nil }) + if err == nil { + t.Fatal("resolveSettings accepted a group that delivered nothing") + } + for _, key := range []string{settingKeys.bucket, settingKeys.warehouseEndpoint, settingKeys.warehouseToken} { + if !strings.Contains(err.Error(), key) { + t.Errorf("the refusal does not name the missing %s: %v", key, err) + } + } +} + +// TestResolveSettingsRefusesEachMissingKeyIndividually proves no single setting +// has a default. A bucket without a warehouse credential, or a warehouse +// without a bucket, is not a startable configuration. +func TestResolveSettingsRefusesEachMissingKeyIndividually(t *testing.T) { + delivered := map[string]string{ + settingKeys.bucket: "example-witness-bucket", + settingKeys.warehouseEndpoint: "https://warehouse.example.com/ingest", + settingKeys.warehouseToken: "example-warehouse-token", + } + for withheld := range delivered { + _, err := resolveSettings(func(_, key string) (string, error) { + if key == withheld { + return "", nil + } + return delivered[key], nil + }) + if err == nil { + t.Errorf("resolveSettings started with %s missing", withheld) + continue + } + if !strings.Contains(err.Error(), withheld) { + t.Errorf("the refusal for a missing %s does not name it: %v", withheld, err) + } + } +} + +// TestResolveSettingsNeverFallsBackToTheProcessEnvironment is the one that +// keeps "no development bypass" true. An ambient bucket or warehouse credential +// would make this service witness somewhere nobody provisioned — and it would +// do so by starting successfully. +func TestResolveSettingsNeverFallsBackToTheProcessEnvironment(t *testing.T) { + t.Setenv(settingKeys.bucket, "ambient-bucket") + t.Setenv(settingKeys.warehouseEndpoint, "https://ambient.example.com/ingest") + t.Setenv(settingKeys.warehouseToken, "ambient-token") + // The group is absent, exactly as it is on a workstation. + resolved, err := resolveSettings(func(string, string) (string, error) { + return "", errors.New("no configuration found for policy-log") + }) + if err == nil { + t.Fatalf("resolveSettings fell back to the process environment and resolved %+v", resolved) + } + for _, ambient := range []string{"ambient-bucket", "ambient.example.com", "ambient-token"} { + if strings.Contains(err.Error(), ambient) { + t.Errorf("the refusal echoed an ambient value %q", ambient) + } + } +} + +// TestResolveSettingsNeverEchoesADeliveredValue: one of the three settings is a +// credential, and a startup refusal is the most widely read log line a service +// has. The refusal names keys, never values. +func TestResolveSettingsNeverEchoesADeliveredValue(t *testing.T) { + const credential = "s3cr3t-warehouse-token" + _, err := resolveSettings(func(_, key string) (string, error) { + switch key { + case settingKeys.warehouseToken: + return credential, nil + case settingKeys.bucket: + return "example-witness-bucket", nil + } + return "", nil + }) + if err == nil { + t.Fatal("resolveSettings accepted a group with no warehouse endpoint") + } + for _, value := range []string{credential, "example-witness-bucket"} { + if strings.Contains(err.Error(), value) { + t.Fatalf("the refusal echoed a delivered value %q: %v", value, err) + } + } +} + +// TestResolveSettingsAcceptsAWholeGroup is the positive half: a fully delivered +// group resolves, and each value lands in its own field rather than being +// transposed. +func TestResolveSettingsAcceptsAWholeGroup(t *testing.T) { + resolved, err := resolveSettings(func(group, key string) (string, error) { + if group != policyLogConfiguration { + t.Fatalf("resolveSettings read group %q, want %q", group, policyLogConfiguration) + } + switch key { + case settingKeys.bucket: + return "example-witness-bucket", nil + case settingKeys.warehouseEndpoint: + return "https://warehouse.example.com/ingest", nil + case settingKeys.warehouseToken: + return "example-warehouse-token", nil + } + t.Fatalf("resolveSettings read an undeclared key %q", key) + return "", nil + }) + if err != nil { + t.Fatalf("resolveSettings: %v", err) + } + if resolved.bucket != "example-witness-bucket" || + resolved.warehouseEndpoint != "https://warehouse.example.com/ingest" || + resolved.warehouseToken != "example-warehouse-token" { + t.Fatalf("resolveSettings transposed a value: %+v", resolved) + } +} + +type manifest struct { + Name string `yaml:"name"` + Endpoints []struct { + Name string `yaml:"name"` + API string `yaml:"api"` + Visibility string `yaml:"visibility"` + } `yaml:"endpoints"` + ServiceDependencies []struct { + Name string `yaml:"name"` + } `yaml:"service-dependencies"` + Spec struct { + ConnectEndpoint bool `yaml:"connect-endpoint"` + ProtocolOutputDir []string `yaml:"protocol-output-dirs"` + } `yaml:"spec"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + body, err := os.ReadFile("../service.codefly.yaml") + if err != nil { + t.Fatalf("read the service manifest: %v", err) + } + var declared manifest + if err := yaml.Unmarshal(body, &declared); err != nil { + t.Fatalf("parse the service manifest: %v", err) + } + return declared +} + +// TestTheWitnessListenerStaysPrivate is a gate, not a description. The witness +// must be reachable only by the host's declared client through +// workload-authenticated mesh policy: a `visibility` of module or public, a +// second endpoint, a Connect listener or a REST api would each put it in front +// of composed workloads or the public gateway, where a shared credential would +// be enough authority to append. +func TestTheWitnessListenerStaysPrivate(t *testing.T) { + declared := readManifest(t) + if len(declared.Endpoints) != 1 { + t.Fatalf("the witness declares %d endpoints; it must declare exactly the one private listener", len(declared.Endpoints)) + } + endpoint := declared.Endpoints[0] + if endpoint.Name != grpcEndpointName { + t.Errorf("the declared endpoint is %q but the binary looks up %q, so it would bind no port", endpoint.Name, grpcEndpointName) + } + if endpoint.Visibility != "" { + t.Errorf("the witness endpoint declares visibility %q; it must declare none, which is private", endpoint.Visibility) + } + if endpoint.API != "" && endpoint.API != "grpc" { + t.Errorf("the witness endpoint declares api %q; only grpc may be projected", endpoint.API) + } + if declared.Spec.ConnectEndpoint { + t.Error("the witness declares a Connect endpoint, which is browser-reachable through the gateway") + } + for _, dependency := range declared.ServiceDependencies { + t.Errorf("the witness declares a dependency on %q; it depends on nothing in the graph and must not be routed through one", dependency.Name) + } +} + +// TestEveryDeclaredProtocolOutputDirIsGenerated: a declared generated directory +// that no generator writes is a claim nobody can reproduce, and the inverse — +// generated bytes under a directory the manifest does not declare — is output +// Codefly's sync-drift phase does not know it owns. +func TestEveryDeclaredProtocolOutputDirIsGenerated(t *testing.T) { + declared := readManifest(t) + if len(declared.Spec.ProtocolOutputDir) == 0 { + t.Fatal("the manifest declares no protocol output directory, so the agent has no protobuf sync input") + } + for _, dir := range declared.Spec.ProtocolOutputDir { + entries, err := os.ReadDir("../" + dir) + if err != nil { + t.Errorf("declared protocol output dir %q does not exist: %v", dir, err) + continue + } + if len(entries) == 0 { + t.Errorf("declared protocol output dir %q is empty; nothing generates into it", dir) + } + } +} diff --git a/module/services/policy-log/code/mirror/warehouse.go b/module/services/policy-log/code/mirror/warehouse.go new file mode 100644 index 000000000..edc9cb54e --- /dev/null +++ b/module/services/policy-log/code/mirror/warehouse.go @@ -0,0 +1,126 @@ +// Package mirror implements the best-effort warehouse projection. The warehouse +// receives receipts; it never produces them or participates in the head commit. +package mirror + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync/atomic" + "time" + + "policy-log/witness" +) + +// Writer follows the repository's warehouse convention: one JSON object per +// POST with a dedicated bearer credential. No host sink or credential is reused. +type Writer struct { + endpoint, token string + client *http.Client +} + +func NewWriter(endpoint, token string) (*Writer, error) { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || token == "" { + return nil, errors.New("policy-log: warehouse HTTPS endpoint and dedicated writer token are required") + } + return &Writer{endpoint: endpoint, token: token, client: &http.Client{ + Timeout: 5 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }}, nil +} + +func (w *Writer) Write(ctx context.Context, record witness.Record) error { + body, err := json.Marshal(record) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+w.token) + response, err := w.client.Do(req) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + // A broken sink cannot make its response an unbounded read either. + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("policy-log: warehouse HTTP %d", response.StatusCode) + } + return nil +} + +type Sink interface { + Write(context.Context, witness.Record) error +} + +// Worker has bounded memory and concurrency. Dropped or failed mirrors are +// reported and repairable from Read. It has no durable authority of its own. +type Worker struct { + queue chan witness.Record + failed func(error) + done <-chan struct{} + dropped atomic.Uint64 +} + +func Start(ctx context.Context, sink Sink, failed func(error)) (*Worker, error) { + if sink == nil || failed == nil { + return nil, errors.New("policy-log: mirror sink and failure reporter required") + } + w := &Worker{queue: make(chan witness.Record, 64), failed: failed, done: ctx.Done()} + go func() { + var reported uint64 + for { + select { + case <-ctx.Done(): + return + case record := <-w.queue: + if dropped := w.dropped.Load(); dropped > reported { + failed(fmt.Errorf("policy-log: dropped %d warehouse mirrors; repair from Read", dropped-reported)) + reported = dropped + } + attempt, cancel := context.WithTimeout(ctx, 5*time.Second) + err := sink.Write(attempt, record) + cancel() + if err != nil { + failed(err) + } + } + } + }() + return w, nil +} + +// Offer must be called only after Append returned a committed receipt. It never +// waits for warehouse I/O and never changes an append's outcome. +func (w *Worker) Offer(record witness.Record) { + select { + case <-w.done: + w.dropped.Add(1) + return + default: + } + record.Entry.NarrowingHash = bytes.Clone(record.Entry.NarrowingHash) + record.Receipt.EntryHash = bytes.Clone(record.Receipt.EntryHash) + record.Receipt.PrevHash = bytes.Clone(record.Receipt.PrevHash) + record.Receipt.ChainHash = bytes.Clone(record.Receipt.ChainHash) + record.Receipt.Signature = bytes.Clone(record.Receipt.Signature) + select { + case w.queue <- record: + default: + // The reporting callback may itself block on logging. It must never + // run on the append path; expose the monotonic drop count instead. + w.dropped.Add(1) + } +} + +func (w *Worker) Dropped() uint64 { return w.dropped.Load() } diff --git a/module/services/policy-log/code/mirror/warehouse_test.go b/module/services/policy-log/code/mirror/warehouse_test.go new file mode 100644 index 000000000..b43029e21 --- /dev/null +++ b/module/services/policy-log/code/mirror/warehouse_test.go @@ -0,0 +1,125 @@ +package mirror + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "policy-log/witness" +) + +type transportFunc func(*http.Request) (*http.Response, error) + +func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type sinkFunc func(context.Context, witness.Record) error + +func (f sinkFunc) Write(ctx context.Context, r witness.Record) error { return f(ctx, r) } + +func TestWarehouseWriterUsesDedicatedCredentialAndCommittedRecord(t *testing.T) { + w, err := NewWriter("https://warehouse.example/policy-log", "example-writer-token") + if err != nil { + t.Fatal(err) + } + w.client.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer example-writer-token" || r.Header.Get("Content-Type") != "application/json" { + t.Errorf("wrong warehouse request: %s, %v", r.Method, r.Header) + } + var record witness.Record + if err := json.NewDecoder(r.Body).Decode(&record); err != nil || record.Receipt.Seq != 7 { + t.Errorf("lost receipt: %+v, %v", record, err) + } + return &http.Response{StatusCode: 204, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + }) + if err := w.Write(context.Background(), witness.Record{Receipt: witness.Receipt{Seq: 7}}); err != nil { + t.Fatal(err) + } +} + +func TestWarehouseWriterRefusesRedirectsAndUnsafeConfiguration(t *testing.T) { + for _, endpoint := range []string{"", "http://warehouse.example/log", "https://user:password@warehouse.example/log", "https://warehouse.example/log#fragment"} { + if _, err := NewWriter(endpoint, "example-token"); err == nil { + t.Fatalf("accepted %q", endpoint) + } + } + if _, err := NewWriter("https://warehouse.example/log", ""); err == nil { + t.Fatal("accepted absent credential") + } + w, err := NewWriter("https://warehouse.example/log", "example-token") + if err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + w.client.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { + calls.Add(1) + return &http.Response{StatusCode: 302, Header: http.Header{"Location": []string{"https://other.example/steal"}}, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + if err := w.Write(context.Background(), witness.Record{}); err == nil || calls.Load() != 1 { + t.Fatalf("redirect followed: calls=%d, err=%v", calls.Load(), err) + } +} + +func TestSlowFailingMirrorNeverBlocksOffer(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + entered := make(chan struct{}) + sink := sinkFunc(func(ctx context.Context, _ witness.Record) error { + select { + case <-entered: + default: + close(entered) + } + <-ctx.Done() + return ctx.Err() + }) + w, err := Start(ctx, sink, func(error) {}) + if err != nil { + t.Fatal(err) + } + w.Offer(witness.Record{}) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("mirror not called") + } + done := make(chan struct{}) + go func() { + for range 1000 { + w.Offer(witness.Record{}) + } + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("mirror blocked append path") + } + if w.Dropped() == 0 { + t.Fatal("queue was not bounded or losses hidden") + } +} + +func TestWarehouseFailureIsReportedOutsideAppendPath(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + failure := make(chan error, 1) + w, err := Start(ctx, sinkFunc(func(context.Context, witness.Record) error { return errors.New("warehouse unavailable") }), func(err error) { failure <- err }) + if err != nil { + t.Fatal(err) + } + w.Offer(witness.Record{}) + select { + case err := <-failure: + if err == nil { + t.Fatal("failure hidden") + } + case <-time.After(time.Second): + t.Fatal("failure not reported") + } +} diff --git a/module/services/policy-log/code/objectstore/gcs/gcs.go b/module/services/policy-log/code/objectstore/gcs/gcs.go new file mode 100644 index 000000000..1c56f42a0 --- /dev/null +++ b/module/services/policy-log/code/objectstore/gcs/gcs.go @@ -0,0 +1,131 @@ +// Package gcs implements the witness's conditional object-store seam. +package gcs + +import ( + "context" + "errors" + "fmt" + "hash/crc32" + "io" + "os" + "regexp" + + "policy-log/objectstore" + + "cloud.google.com/go/storage" + "google.golang.org/api/googleapi" +) + +var entryName = regexp.MustCompile(`^entries/[0-9]{20}\.json$`) + +// GCS owns a bucket in the witness trust domain, never a host database or a +// host-controlled bucket. It performs one attempt per write, even for 5xx. +type GCS struct { + client *storage.Client + bucket *storage.BucketHandle +} + +// New uses the service's workload identity and the official GCS endpoint. There +// is no endpoint or emulator fallback in production. Bucket identity is delivery +// data; IAM and the service's own workload identity enforce its trust domain. +func New(ctx context.Context, bucket string) (*GCS, error) { + if bucket == "" { + return nil, errors.New("policy-log: GCS bucket is required") + } + if os.Getenv("STORAGE_EMULATOR_HOST") != "" { + return nil, errors.New("policy-log: storage emulator is forbidden") + } + client, err := storage.NewClient(ctx, storage.WithJSONReads()) + if err != nil { + return nil, err + } + return newGCS(client, bucket), nil +} + +func newGCS(client *storage.Client, bucket string) *GCS { + return &GCS{client: client, bucket: client.Bucket(bucket).Retryer(storage.WithPolicy(storage.RetryNever))} +} + +func (s *GCS) Close() error { return s.client.Close() } + +func (s *GCS) Read(ctx context.Context, name string) (objectstore.Object, error) { + if err := ctx.Err(); err != nil { + return objectstore.Object{}, err + } + if name != objectstore.HeadObject && !entryName.MatchString(name) { + return objectstore.Object{}, errors.New("invalid policy-log object name") + } + reader, err := s.bucket.Object(name).NewReader(ctx) + if err != nil { + return objectstore.Object{}, classify(err) + } + defer func() { _ = reader.Close() }() + data, err := io.ReadAll(io.LimitReader(reader, objectstore.MaxObjectBytes+1)) + if err != nil { + return objectstore.Object{}, classify(err) + } + if err := ctx.Err(); err != nil { + return objectstore.Object{}, err + } + if len(data) > objectstore.MaxObjectBytes || reader.Attrs.Generation <= 0 { + return objectstore.Object{}, errors.New("invalid policy-log object size or generation") + } + // Data and generation come from ONE response, never an Attrs/read race. + return objectstore.Object{Data: data, Generation: reader.Attrs.Generation}, nil +} + +func (s *GCS) Write(ctx context.Context, name string, data []byte, generation int64) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if len(data) > objectstore.MaxObjectBytes || generation < 0 { + return 0, errors.New("invalid policy-log object write") + } + if name == objectstore.HeadObject { + // Genesis is provisioning, not a runtime reset path. + if generation == 0 { + return 0, errors.New("policy-log head must be provisioned before boot") + } + } else if !entryName.MatchString(name) || generation != 0 { + return 0, errors.New("entries must be immutable, sequence-named objects") + } + condition := storage.Conditions{GenerationMatch: generation} + if generation == 0 { + condition = storage.Conditions{DoesNotExist: true} + } + writer := s.bucket.Object(name).If(condition).NewWriter(ctx) + writer.ChunkSize = 0 // one bounded upload; no resumable session or retry + writer.ContentType = "application/json" + writer.CacheControl = "no-store" + writer.CRC32C = crc32.Checksum(data, crc32.MakeTable(crc32.Castagnoli)) + writer.SendCRC32C = true + if _, err := writer.Write(data); err != nil { + _ = writer.CloseWithError(err) + return 0, classify(err) + } + if err := ctx.Err(); err != nil { + _ = writer.CloseWithError(err) + return 0, err + } + if err := writer.Close(); err != nil { + return 0, classify(err) + } + if err := ctx.Err(); err != nil { + return 0, err + } + if writer.Attrs().Generation <= generation { + return 0, errors.New("policy-log write did not advance object generation") + } + return writer.Attrs().Generation, nil +} + +func classify(err error) error { + if errors.Is(err, storage.ErrObjectNotExist) { + return objectstore.ErrNotFound + } + var apiError *googleapi.Error + if errors.As(err, &apiError) && apiError.Code == 412 { + return objectstore.ErrPrecondition + } + return fmt.Errorf("policy-log GCS: %w", err) +} diff --git a/module/services/policy-log/code/objectstore/gcs/gcs_test.go b/module/services/policy-log/code/objectstore/gcs/gcs_test.go new file mode 100644 index 000000000..1d48fa7c8 --- /dev/null +++ b/module/services/policy-log/code/objectstore/gcs/gcs_test.go @@ -0,0 +1,167 @@ +package gcs + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "policy-log/objectstore" + + "cloud.google.com/go/storage" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" +) + +type transportFunc func(*http.Request) (*http.Response, error) + +func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func response(code int, body string) *http.Response { + return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), ContentLength: int64(len(body))} +} + +func fakeGCS(t *testing.T, transport transportFunc) *GCS { + t.Helper() + client, err := storage.NewClient(context.Background(), option.WithHTTPClient(&http.Client{Transport: transport}), + option.WithoutAuthentication(), storage.WithJSONReads(), storage.WithDisabledClientMetrics()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return newGCS(client, "example-policy-log") +} + +func TestGCSWritesUseExactGenerationPreconditions(t *testing.T) { + for _, tc := range []struct { + name string + generation int64 + want string + }{{objectstore.HeadObject, 41, "41"}, {"entries/00000000000000000001.json", 0, "0"}} { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + s := fakeGCS(t, func(r *http.Request) (*http.Response, error) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Query().Get("ifGenerationMatch") != tc.want || r.URL.Query().Get("uploadType") != "multipart" { + t.Errorf("conditional upload = %s %s", r.Method, r.URL) + } + b, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if !strings.Contains(string(b), `"crc32c"`) || !strings.Contains(string(b), `"cacheControl":"no-store"`) || !strings.Contains(string(b), `{"seq":1}`) { + t.Errorf("upload omitted checksum, cache policy or bytes: %s", b) + } + return response(200, `{"generation":"42"}`), nil + }) + generation, err := s.Write(context.Background(), tc.name, []byte(`{"seq":1}`), tc.generation) + if err != nil || generation != 42 || calls.Load() != 1 { + t.Fatalf("Write: generation=%d calls=%d err=%v", generation, calls.Load(), err) + } + }) + } +} + +func TestGCSLostPreconditionAndServerFailureNeverRetry(t *testing.T) { + for _, code := range []int{412, 503} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + var calls atomic.Int32 + s := fakeGCS(t, func(r *http.Request) (*http.Response, error) { + calls.Add(1) + _, _ = io.Copy(io.Discard, r.Body) + return response(code, fmt.Sprintf(`{"error":{"code":%d,"message":"refused"}}`, code)), nil + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + generation, err := s.Write(ctx, objectstore.HeadObject, []byte(`{}`), 7) + if err == nil || generation != 0 || calls.Load() != 1 { + t.Fatalf("Write retried or succeeded: generation=%d calls=%d err=%v", generation, calls.Load(), err) + } + if code == 412 && !errors.Is(err, objectstore.ErrPrecondition) { + t.Fatalf("lost generation was not classified: %v", err) + } + var apiError *googleapi.Error + if code == 503 && (!errors.As(err, &apiError) || apiError.Code != 503) { + t.Fatalf("server failure retried until deadline: %v", err) + } + }) + } +} + +func TestGCSReadReturnsBytesAndGenerationFromOneResponse(t *testing.T) { + var calls atomic.Int32 + s := fakeGCS(t, func(r *http.Request) (*http.Response, error) { + calls.Add(1) + if r.Method != http.MethodGet || r.URL.Query().Get("alt") != "media" { + t.Errorf("not one object read: %s %s", r.Method, r.URL) + } + rsp := response(200, `{"seq":1}`) + rsp.Header.Set("X-Goog-Generation", "19") + rsp.Header.Set("Content-Type", "application/json") + rsp.Header.Set("Content-Length", fmt.Sprint(rsp.ContentLength)) + return rsp, nil + }) + obj, err := s.Read(context.Background(), objectstore.HeadObject) + if err != nil || obj.Generation != 19 || string(obj.Data) != `{"seq":1}` || calls.Load() != 1 { + t.Fatalf("Read: %+v calls=%d err=%v", obj, calls.Load(), err) + } +} + +func TestGCSRefusesUnconditionalHeadAndEntryOverwrite(t *testing.T) { + s := fakeGCS(t, func(*http.Request) (*http.Response, error) { + t.Error("invalid write reached GCS") + return nil, errors.New("unexpected") + }) + for _, tc := range []struct { + name string + generation int64 + }{{objectstore.HeadObject, 0}, {objectstore.HeadObject, -1}, {"entries/00000000000000000001.json", 9}, {"other.json", 0}, {"entries/../../head.json", 0}} { + if _, err := s.Write(context.Background(), tc.name, []byte(`{}`), tc.generation); err == nil { + t.Fatalf("accepted %+v", tc) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := s.Write(ctx, objectstore.HeadObject, []byte(`{}`), 1); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled write: %v", err) + } + if _, err := s.Read(ctx, objectstore.HeadObject); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled read: %v", err) + } +} + +func TestGCSRejectsMissingOversizedAndGenerationlessObjects(t *testing.T) { + for _, tc := range []struct { + name string + code int + body, generation string + }{{"missing", 404, `{"error":{"code":404}}`, ""}, {"oversized", 200, strings.Repeat("x", objectstore.MaxObjectBytes+1), "2"}, {"generationless", 200, `{}`, ""}} { + t.Run(tc.name, func(t *testing.T) { + s := fakeGCS(t, func(*http.Request) (*http.Response, error) { + r := response(tc.code, tc.body) + r.Header.Set("Content-Length", fmt.Sprint(r.ContentLength)) + r.Header.Set("X-Goog-Generation", tc.generation) + return r, nil + }) + _, err := s.Read(context.Background(), objectstore.HeadObject) + if err == nil { + t.Fatal("invalid read accepted") + } + if tc.code == 404 && !errors.Is(err, objectstore.ErrNotFound) { + t.Fatalf("missing classification: %v", err) + } + }) + } +} + +func TestGCSBootRefusesEmulatorOverride(t *testing.T) { + t.Setenv("STORAGE_EMULATOR_HOST", "http://example.invalid") + if s, err := New(context.Background(), "example-policy-log"); s != nil || err == nil { + t.Fatal("emulator override accepted") + } +} diff --git a/module/services/policy-log/code/objectstore/memory/memory.go b/module/services/policy-log/code/objectstore/memory/memory.go new file mode 100644 index 000000000..d7ecf8aff --- /dev/null +++ b/module/services/policy-log/code/objectstore/memory/memory.go @@ -0,0 +1,52 @@ +// Package memory supplies the generation-aware fake used by policy-log tests. +// The served process never imports it. +package memory + +import ( + "bytes" + "context" + "fmt" + "sync" + + "policy-log/objectstore" +) + +type Store struct { + mu sync.Mutex + objects map[string]objectstore.Object + generation int64 +} + +func New() *Store { return &Store{objects: make(map[string]objectstore.Object)} } + +func (s *Store) Read(ctx context.Context, name string) (objectstore.Object, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return objectstore.Object{}, err + } + obj, ok := s.objects[name] + if !ok { + return objectstore.Object{}, objectstore.ErrNotFound + } + obj.Data = bytes.Clone(obj.Data) + return obj, nil +} + +func (s *Store) Write(ctx context.Context, name string, data []byte, generation int64) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return 0, err + } + if generation < 0 || len(data) > objectstore.MaxObjectBytes { + return 0, fmt.Errorf("invalid object write") + } + obj, exists := s.objects[name] + if (generation == 0 && exists) || (generation > 0 && (!exists || obj.Generation != generation)) { + return 0, objectstore.ErrPrecondition + } + s.generation++ + s.objects[name] = objectstore.Object{Data: bytes.Clone(data), Generation: s.generation} + return s.generation, nil +} diff --git a/module/services/policy-log/code/objectstore/store.go b/module/services/policy-log/code/objectstore/store.go new file mode 100644 index 000000000..c6083277d --- /dev/null +++ b/module/services/policy-log/code/objectstore/store.go @@ -0,0 +1,31 @@ +// Package objectstore is the policy log's only persistence capability. +package objectstore + +import ( + "context" + "errors" +) + +var ( + ErrNotFound = errors.New("object not found") + ErrPrecondition = errors.New("object generation precondition lost") +) + +const MaxObjectBytes = 64 << 10 + +const HeadObject = "head.json" + +type Object struct { + Data []byte + Generation int64 +} + +// Store must provide strongly consistent reads and atomic generation checks. +// Write with generation zero creates an absent object; a positive generation +// replaces exactly that generation. Neither method retries a write. Both must +// honor cancellation, including immediately before a mutation. There is no +// delete, unconditional overwrite, listing, or database capability. +type Store interface { + Read(context.Context, string) (Object, error) + Write(context.Context, string, []byte, int64) (int64, error) +} diff --git a/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness.pb.go b/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness.pb.go new file mode 100644 index 000000000..a267023d6 --- /dev/null +++ b/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness.pb.go @@ -0,0 +1,917 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/policylog/v1/witness.proto + +package policylogv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Entry is one operation offered to the witness. +// +// It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: +// narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and +// the witness stores that digest rather than the operation. Reconciliation can +// identify an operation and verify its bytes; it cannot reconstruct them, so +// the caller must retain or recover the canonical bytes to apply it. +// +// Every field takes part in entry_hash, so every field must stay IDENTICAL on a +// retry of the same operation_id — requested_at included. The witness refuses a +// reused operation_id that names different bytes. +type Entry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // operation_id identifies one logical operation for the lifetime of the log, + // and it is the caller's: only the caller knows that two attempts are the + // same operation. At most 1024 bytes of control-character-free UTF-8. + OperationId string `protobuf:"bytes,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + // kind is what the operation is about, at most 128 bytes. + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + // subject is what the operation acts on, at most 1024 bytes. + Subject string `protobuf:"bytes,3,opt,name=subject,proto3" json:"subject,omitempty"` + // tenant may be empty for a global operation. It grants no authority: this + // log performs no tenant scoping and the field is an identifier, not a claim. + Tenant string `protobuf:"bytes,4,opt,name=tenant,proto3" json:"tenant,omitempty"` + // narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical + // operation bytes, carried raw rather than hex or base64 so the digest the + // signature covers is the digest on the wire. + NarrowingHash []byte `protobuf:"bytes,5,opt,name=narrowing_hash,json=narrowingHash,proto3" json:"narrowing_hash,omitempty"` + // requested_at is the CALLER'S logical timestamp for the operation, not a + // deadline and not an ordering authority. It is covered by entry_hash, so an + // old operation can be retried without changing its identity only as long as + // this value is sent back unchanged. + RequestedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=requested_at,json=requestedAt,proto3" json:"requested_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Entry) Reset() { + *x = Entry{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Entry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Entry) ProtoMessage() {} + +func (x *Entry) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Entry.ProtoReflect.Descriptor instead. +func (*Entry) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{0} +} + +func (x *Entry) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *Entry) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *Entry) GetSubject() string { + if x != nil { + return x.Subject + } + return "" +} + +func (x *Entry) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *Entry) GetNarrowingHash() []byte { + if x != nil { + return x.NarrowingHash + } + return nil +} + +func (x *Entry) GetRequestedAt() *timestamppb.Timestamp { + if x != nil { + return x.RequestedAt + } + return nil +} + +// Receipt is the witness's signed acknowledgement of one committed entry. +// +// Only a committed head makes a receipt exist. A signature over a prepared +// candidate that never reached the head is not a receipt and is never returned. +type Receipt struct { + state protoimpl.MessageState `protogen:"open.v1"` + // seq is the global sequence the witness assigned, never zero and never + // supplied by a caller. The first sequence is 1. Sequence, not clock time, + // orders operations. + Seq uint64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` + // entry_hash is the 32-byte digest of the appended entry. A caller compares + // it against the bytes it asked to append. + EntryHash []byte `protobuf:"bytes,2,opt,name=entry_hash,json=entryHash,proto3" json:"entry_hash,omitempty"` + // prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. + PrevHash []byte `protobuf:"bytes,3,opt,name=prev_hash,json=prevHash,proto3" json:"prev_hash,omitempty"` + // chain_hash is the 32-byte chain hash at seq. + ChainHash []byte `protobuf:"bytes,4,opt,name=chain_hash,json=chainHash,proto3" json:"chain_hash,omitempty"` + // issued_at comes from the witness, never from a caller. + IssuedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` + // signature is 64 bytes of Ed25519 over the canonical receipt encoding, which + // depends on no protobuf field ordering. + Signature []byte `protobuf:"bytes,6,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Receipt) Reset() { + *x = Receipt{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Receipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Receipt) ProtoMessage() {} + +func (x *Receipt) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Receipt.ProtoReflect.Descriptor instead. +func (*Receipt) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{1} +} + +func (x *Receipt) GetSeq() uint64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *Receipt) GetEntryHash() []byte { + if x != nil { + return x.EntryHash + } + return nil +} + +func (x *Receipt) GetPrevHash() []byte { + if x != nil { + return x.PrevHash + } + return nil +} + +func (x *Receipt) GetChainHash() []byte { + if x != nil { + return x.ChainHash + } + return nil +} + +func (x *Receipt) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *Receipt) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// Record is one entry together with the original receipt the witness issued for +// it, which is what makes a read independently verifiable. +type Record struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + Receipt *Receipt `protobuf:"bytes,2,opt,name=receipt,proto3" json:"receipt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Record) Reset() { + *x = Record{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Record) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Record) ProtoMessage() {} + +func (x *Record) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Record.ProtoReflect.Descriptor instead. +func (*Record) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{2} +} + +func (x *Record) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +func (x *Record) GetReceipt() *Receipt { + if x != nil { + return x.Receipt + } + return nil +} + +// Cursor is a signed OBSERVATION of the committed head. It is neither a receipt +// nor a reset instruction: a verifying client never lowers a persisted cursor, +// and a valid signature cannot by itself distinguish a historical cursor from a +// current one, so the client also enforces its own freshness policy. +type Cursor struct { + state protoimpl.MessageState `protogen:"open.v1"` + HeadSeq uint64 `protobuf:"varint,1,opt,name=head_seq,json=headSeq,proto3" json:"head_seq,omitempty"` + ChainHash []byte `protobuf:"bytes,2,opt,name=chain_hash,json=chainHash,proto3" json:"chain_hash,omitempty"` + IssuedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=issued_at,json=issuedAt,proto3" json:"issued_at,omitempty"` + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Cursor) Reset() { + *x = Cursor{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Cursor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Cursor) ProtoMessage() {} + +func (x *Cursor) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Cursor.ProtoReflect.Descriptor instead. +func (*Cursor) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{3} +} + +func (x *Cursor) GetHeadSeq() uint64 { + if x != nil { + return x.HeadSeq + } + return 0 +} + +func (x *Cursor) GetChainHash() []byte { + if x != nil { + return x.ChainHash + } + return nil +} + +func (x *Cursor) GetIssuedAt() *timestamppb.Timestamp { + if x != nil { + return x.IssuedAt + } + return nil +} + +func (x *Cursor) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// Key is the public half of the witness signing key, and the same schema as the +// trust-anchor document composition delivers to the host. Publishing it is +// discovery, never authority to replace a caller's pinned key. +type Key struct { + state protoimpl.MessageState `protogen:"open.v1"` + // algorithm is "Ed25519". + Algorithm string `protobuf:"bytes,1,opt,name=algorithm,proto3" json:"algorithm,omitempty"` + // key_id is the lowercase SHA-256 hex of the raw public key. + KeyId string `protobuf:"bytes,2,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + // public_key is the raw 32-byte public key. + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Key) Reset() { + *x = Key{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Key) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Key) ProtoMessage() {} + +func (x *Key) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Key.ProtoReflect.Descriptor instead. +func (*Key) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{4} +} + +func (x *Key) GetAlgorithm() string { + if x != nil { + return x.Algorithm + } + return "" +} + +func (x *Key) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *Key) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +// AppendRequest offers one entry. +type AppendRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendRequest) Reset() { + *x = AppendRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendRequest) ProtoMessage() {} + +func (x *AppendRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendRequest.ProtoReflect.Descriptor instead. +func (*AppendRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{5} +} + +func (x *AppendRequest) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +// AppendResponse carries the committed receipt. A retry of the identical entry +// returns the exact original receipt rather than appending again. +type AppendResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *Receipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendResponse) Reset() { + *x = AppendResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendResponse) ProtoMessage() {} + +func (x *AppendResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendResponse.ProtoReflect.Descriptor instead. +func (*AppendResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{6} +} + +func (x *AppendResponse) GetReceipt() *Receipt { + if x != nil { + return x.Receipt + } + return nil +} + +// CursorRequest takes no argument: there is one log and one head. +type CursorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CursorRequest) Reset() { + *x = CursorRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CursorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CursorRequest) ProtoMessage() {} + +func (x *CursorRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CursorRequest.ProtoReflect.Descriptor instead. +func (*CursorRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{7} +} + +// CursorResponse carries the signed head observation. +type CursorResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cursor *Cursor `protobuf:"bytes,1,opt,name=cursor,proto3" json:"cursor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CursorResponse) Reset() { + *x = CursorResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CursorResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CursorResponse) ProtoMessage() {} + +func (x *CursorResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CursorResponse.ProtoReflect.Descriptor instead. +func (*CursorResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{8} +} + +func (x *CursorResponse) GetCursor() *Cursor { + if x != nil { + return x.Cursor + } + return nil +} + +// ReadRequest asks for a bounded page of committed records. +type ReadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an + // empty page; asking beyond it is refused, so a reconciler that is ahead of + // the log cannot mistake a rollback for inactivity. A caller holding an + // exclusive cursor sends cursor + 1. + FromSeq uint64 `protobuf:"varint,1,opt,name=from_seq,json=fromSeq,proto3" json:"from_seq,omitempty"` + // limit is 1 through 500. + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadRequest) Reset() { + *x = ReadRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadRequest) ProtoMessage() {} + +func (x *ReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadRequest.ProtoReflect.Descriptor instead. +func (*ReadRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{9} +} + +func (x *ReadRequest) GetFromSeq() uint64 { + if x != nil { + return x.FromSeq + } + return 0 +} + +func (x *ReadRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +// ReadResponse carries the page in ascending sequence order. +type ReadResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Records []*Record `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadResponse) Reset() { + *x = ReadResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadResponse) ProtoMessage() {} + +func (x *ReadResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadResponse.ProtoReflect.Descriptor instead. +func (*ReadResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{10} +} + +func (x *ReadResponse) GetRecords() []*Record { + if x != nil { + return x.Records + } + return nil +} + +// KeysRequest takes no argument. +type KeysRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *KeysRequest) Reset() { + *x = KeysRequest{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *KeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KeysRequest) ProtoMessage() {} + +func (x *KeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KeysRequest.ProtoReflect.Descriptor instead. +func (*KeysRequest) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{11} +} + +// KeysResponse carries the public signing key document. +type KeysResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key *Key `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *KeysResponse) Reset() { + *x = KeysResponse{} + mi := &file_saas_policylog_v1_witness_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *KeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KeysResponse) ProtoMessage() {} + +func (x *KeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_policylog_v1_witness_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. +func (*KeysResponse) Descriptor() ([]byte, []int) { + return file_saas_policylog_v1_witness_proto_rawDescGZIP(), []int{12} +} + +func (x *KeysResponse) GetKey() *Key { + if x != nil { + return x.Key + } + return nil +} + +var File_saas_policylog_v1_witness_proto protoreflect.FileDescriptor + +const file_saas_policylog_v1_witness_proto_rawDesc = "" + + "\n" + + "\x1fsaas/policylog/v1/witness.proto\x12\x11saas.policylog.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd6\x01\n" + + "\x05Entry\x12!\n" + + "\foperation_id\x18\x01 \x01(\tR\voperationId\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + + "\asubject\x18\x03 \x01(\tR\asubject\x12\x16\n" + + "\x06tenant\x18\x04 \x01(\tR\x06tenant\x12%\n" + + "\x0enarrowing_hash\x18\x05 \x01(\fR\rnarrowingHash\x12=\n" + + "\frequested_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vrequestedAt\"\xcd\x01\n" + + "\aReceipt\x12\x10\n" + + "\x03seq\x18\x01 \x01(\x04R\x03seq\x12\x1d\n" + + "\n" + + "entry_hash\x18\x02 \x01(\fR\tentryHash\x12\x1b\n" + + "\tprev_hash\x18\x03 \x01(\fR\bprevHash\x12\x1d\n" + + "\n" + + "chain_hash\x18\x04 \x01(\fR\tchainHash\x127\n" + + "\tissued_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x12\x1c\n" + + "\tsignature\x18\x06 \x01(\fR\tsignature\"n\n" + + "\x06Record\x12.\n" + + "\x05entry\x18\x01 \x01(\v2\x18.saas.policylog.v1.EntryR\x05entry\x124\n" + + "\areceipt\x18\x02 \x01(\v2\x1a.saas.policylog.v1.ReceiptR\areceipt\"\x99\x01\n" + + "\x06Cursor\x12\x19\n" + + "\bhead_seq\x18\x01 \x01(\x04R\aheadSeq\x12\x1d\n" + + "\n" + + "chain_hash\x18\x02 \x01(\fR\tchainHash\x127\n" + + "\tissued_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\bissuedAt\x12\x1c\n" + + "\tsignature\x18\x04 \x01(\fR\tsignature\"Y\n" + + "\x03Key\x12\x1c\n" + + "\talgorithm\x18\x01 \x01(\tR\talgorithm\x12\x15\n" + + "\x06key_id\x18\x02 \x01(\tR\x05keyId\x12\x1d\n" + + "\n" + + "public_key\x18\x03 \x01(\fR\tpublicKey\"?\n" + + "\rAppendRequest\x12.\n" + + "\x05entry\x18\x01 \x01(\v2\x18.saas.policylog.v1.EntryR\x05entry\"F\n" + + "\x0eAppendResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.saas.policylog.v1.ReceiptR\areceipt\"\x0f\n" + + "\rCursorRequest\"C\n" + + "\x0eCursorResponse\x121\n" + + "\x06cursor\x18\x01 \x01(\v2\x19.saas.policylog.v1.CursorR\x06cursor\">\n" + + "\vReadRequest\x12\x19\n" + + "\bfrom_seq\x18\x01 \x01(\x04R\afromSeq\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\"C\n" + + "\fReadResponse\x123\n" + + "\arecords\x18\x01 \x03(\v2\x19.saas.policylog.v1.RecordR\arecords\"\r\n" + + "\vKeysRequest\"8\n" + + "\fKeysResponse\x12(\n" + + "\x03key\x18\x01 \x01(\v2\x16.saas.policylog.v1.KeyR\x03key2\xc0\x02\n" + + "\x0eWitnessService\x12M\n" + + "\x06Append\x12 .saas.policylog.v1.AppendRequest\x1a!.saas.policylog.v1.AppendResponse\x12M\n" + + "\x06Cursor\x12 .saas.policylog.v1.CursorRequest\x1a!.saas.policylog.v1.CursorResponse\x12G\n" + + "\x04Read\x12\x1e.saas.policylog.v1.ReadRequest\x1a\x1f.saas.policylog.v1.ReadResponse\x12G\n" + + "\x04Keys\x12\x1e.saas.policylog.v1.KeysRequest\x1a\x1f.saas.policylog.v1.KeysResponseB\xbd\x01\n" + + "\x15com.saas.policylog.v1B\fWitnessProtoP\x01Z0policy-log/pkg/gen/saas/policylog/v1;policylogv1\xa2\x02\x03SPX\xaa\x02\x11Saas.Policylog.V1\xca\x02\x11Saas\\Policylog\\V1\xe2\x02\x1dSaas\\Policylog\\V1\\GPBMetadata\xea\x02\x13Saas::Policylog::V1b\x06proto3" + +var ( + file_saas_policylog_v1_witness_proto_rawDescOnce sync.Once + file_saas_policylog_v1_witness_proto_rawDescData []byte +) + +func file_saas_policylog_v1_witness_proto_rawDescGZIP() []byte { + file_saas_policylog_v1_witness_proto_rawDescOnce.Do(func() { + file_saas_policylog_v1_witness_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_policylog_v1_witness_proto_rawDesc), len(file_saas_policylog_v1_witness_proto_rawDesc))) + }) + return file_saas_policylog_v1_witness_proto_rawDescData +} + +var file_saas_policylog_v1_witness_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_saas_policylog_v1_witness_proto_goTypes = []any{ + (*Entry)(nil), // 0: saas.policylog.v1.Entry + (*Receipt)(nil), // 1: saas.policylog.v1.Receipt + (*Record)(nil), // 2: saas.policylog.v1.Record + (*Cursor)(nil), // 3: saas.policylog.v1.Cursor + (*Key)(nil), // 4: saas.policylog.v1.Key + (*AppendRequest)(nil), // 5: saas.policylog.v1.AppendRequest + (*AppendResponse)(nil), // 6: saas.policylog.v1.AppendResponse + (*CursorRequest)(nil), // 7: saas.policylog.v1.CursorRequest + (*CursorResponse)(nil), // 8: saas.policylog.v1.CursorResponse + (*ReadRequest)(nil), // 9: saas.policylog.v1.ReadRequest + (*ReadResponse)(nil), // 10: saas.policylog.v1.ReadResponse + (*KeysRequest)(nil), // 11: saas.policylog.v1.KeysRequest + (*KeysResponse)(nil), // 12: saas.policylog.v1.KeysResponse + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp +} +var file_saas_policylog_v1_witness_proto_depIdxs = []int32{ + 13, // 0: saas.policylog.v1.Entry.requested_at:type_name -> google.protobuf.Timestamp + 13, // 1: saas.policylog.v1.Receipt.issued_at:type_name -> google.protobuf.Timestamp + 0, // 2: saas.policylog.v1.Record.entry:type_name -> saas.policylog.v1.Entry + 1, // 3: saas.policylog.v1.Record.receipt:type_name -> saas.policylog.v1.Receipt + 13, // 4: saas.policylog.v1.Cursor.issued_at:type_name -> google.protobuf.Timestamp + 0, // 5: saas.policylog.v1.AppendRequest.entry:type_name -> saas.policylog.v1.Entry + 1, // 6: saas.policylog.v1.AppendResponse.receipt:type_name -> saas.policylog.v1.Receipt + 3, // 7: saas.policylog.v1.CursorResponse.cursor:type_name -> saas.policylog.v1.Cursor + 2, // 8: saas.policylog.v1.ReadResponse.records:type_name -> saas.policylog.v1.Record + 4, // 9: saas.policylog.v1.KeysResponse.key:type_name -> saas.policylog.v1.Key + 5, // 10: saas.policylog.v1.WitnessService.Append:input_type -> saas.policylog.v1.AppendRequest + 7, // 11: saas.policylog.v1.WitnessService.Cursor:input_type -> saas.policylog.v1.CursorRequest + 9, // 12: saas.policylog.v1.WitnessService.Read:input_type -> saas.policylog.v1.ReadRequest + 11, // 13: saas.policylog.v1.WitnessService.Keys:input_type -> saas.policylog.v1.KeysRequest + 6, // 14: saas.policylog.v1.WitnessService.Append:output_type -> saas.policylog.v1.AppendResponse + 8, // 15: saas.policylog.v1.WitnessService.Cursor:output_type -> saas.policylog.v1.CursorResponse + 10, // 16: saas.policylog.v1.WitnessService.Read:output_type -> saas.policylog.v1.ReadResponse + 12, // 17: saas.policylog.v1.WitnessService.Keys:output_type -> saas.policylog.v1.KeysResponse + 14, // [14:18] is the sub-list for method output_type + 10, // [10:14] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name +} + +func init() { file_saas_policylog_v1_witness_proto_init() } +func file_saas_policylog_v1_witness_proto_init() { + if File_saas_policylog_v1_witness_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_policylog_v1_witness_proto_rawDesc), len(file_saas_policylog_v1_witness_proto_rawDesc)), + NumEnums: 0, + NumMessages: 13, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_saas_policylog_v1_witness_proto_goTypes, + DependencyIndexes: file_saas_policylog_v1_witness_proto_depIdxs, + MessageInfos: file_saas_policylog_v1_witness_proto_msgTypes, + }.Build() + File_saas_policylog_v1_witness_proto = out.File + file_saas_policylog_v1_witness_proto_goTypes = nil + file_saas_policylog_v1_witness_proto_depIdxs = nil +} diff --git a/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go b/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go new file mode 100644 index 000000000..e0d957df2 --- /dev/null +++ b/module/services/policy-log/code/pkg/gen/saas/policylog/v1/witness_grpc.pb.go @@ -0,0 +1,266 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: saas/policylog/v1/witness.proto + +package policylogv1 + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + WitnessService_Append_FullMethodName = "/saas.policylog.v1.WitnessService/Append" + WitnessService_Cursor_FullMethodName = "/saas.policylog.v1.WitnessService/Cursor" + WitnessService_Read_FullMethodName = "/saas.policylog.v1.WitnessService/Read" + WitnessService_Keys_FullMethodName = "/saas.policylog.v1.WitnessService/Keys" +) + +// WitnessServiceClient is the client API for WitnessService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// WitnessService is the independent append-only witness for authority changes. +// +// Its protocol is append -> signed receipt -> the CALLER commits with the +// receipt in the same transaction. This listener is PRIVATE to the module: it is +// never published through the public gateway and never offered to a composed +// module. Composition admits only the host's declared client, through +// workload-authenticated mesh policy. +type WitnessServiceClient interface { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + Append(ctx context.Context, in *AppendRequest, opts ...grpc.CallOption) (*AppendResponse, error) + // Cursor returns the signed committed head. + Cursor(ctx context.Context, in *CursorRequest, opts ...grpc.CallOption) (*CursorResponse, error) + // Read returns committed records with their original receipts. + Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) +} + +type witnessServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewWitnessServiceClient(cc grpc.ClientConnInterface) WitnessServiceClient { + return &witnessServiceClient{cc} +} + +func (c *witnessServiceClient) Append(ctx context.Context, in *AppendRequest, opts ...grpc.CallOption) (*AppendResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AppendResponse) + err := c.cc.Invoke(ctx, WitnessService_Append_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *witnessServiceClient) Cursor(ctx context.Context, in *CursorRequest, opts ...grpc.CallOption) (*CursorResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CursorResponse) + err := c.cc.Invoke(ctx, WitnessService_Cursor_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *witnessServiceClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReadResponse) + err := c.cc.Invoke(ctx, WitnessService_Read_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *witnessServiceClient) Keys(ctx context.Context, in *KeysRequest, opts ...grpc.CallOption) (*KeysResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(KeysResponse) + err := c.cc.Invoke(ctx, WitnessService_Keys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// WitnessServiceServer is the server API for WitnessService service. +// All implementations must embed UnimplementedWitnessServiceServer +// for forward compatibility. +// +// WitnessService is the independent append-only witness for authority changes. +// +// Its protocol is append -> signed receipt -> the CALLER commits with the +// receipt in the same transaction. This listener is PRIVATE to the module: it is +// never published through the public gateway and never offered to a composed +// module. Composition admits only the host's declared client, through +// workload-authenticated mesh policy. +type WitnessServiceServer interface { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + Append(context.Context, *AppendRequest) (*AppendResponse, error) + // Cursor returns the signed committed head. + Cursor(context.Context, *CursorRequest) (*CursorResponse, error) + // Read returns committed records with their original receipts. + Read(context.Context, *ReadRequest) (*ReadResponse, error) + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + Keys(context.Context, *KeysRequest) (*KeysResponse, error) + mustEmbedUnimplementedWitnessServiceServer() +} + +// UnimplementedWitnessServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedWitnessServiceServer struct{} + +func (UnimplementedWitnessServiceServer) Append(context.Context, *AppendRequest) (*AppendResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Append not implemented") +} +func (UnimplementedWitnessServiceServer) Cursor(context.Context, *CursorRequest) (*CursorResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Cursor not implemented") +} +func (UnimplementedWitnessServiceServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Read not implemented") +} +func (UnimplementedWitnessServiceServer) Keys(context.Context, *KeysRequest) (*KeysResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Keys not implemented") +} +func (UnimplementedWitnessServiceServer) mustEmbedUnimplementedWitnessServiceServer() {} +func (UnimplementedWitnessServiceServer) testEmbeddedByValue() {} + +// UnsafeWitnessServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to WitnessServiceServer will +// result in compilation errors. +type UnsafeWitnessServiceServer interface { + mustEmbedUnimplementedWitnessServiceServer() +} + +func RegisterWitnessServiceServer(s grpc.ServiceRegistrar, srv WitnessServiceServer) { + // If the following call panics, it indicates UnimplementedWitnessServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&WitnessService_ServiceDesc, srv) +} + +func _WitnessService_Append_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AppendRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Append(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Append_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Append(ctx, req.(*AppendRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WitnessService_Cursor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CursorRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Cursor(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Cursor_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Cursor(ctx, req.(*CursorRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WitnessService_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Read(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Read_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Read(ctx, req.(*ReadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WitnessService_Keys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(KeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WitnessServiceServer).Keys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WitnessService_Keys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WitnessServiceServer).Keys(ctx, req.(*KeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// WitnessService_ServiceDesc is the grpc.ServiceDesc for WitnessService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var WitnessService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "saas.policylog.v1.WitnessService", + HandlerType: (*WitnessServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Append", + Handler: _WitnessService_Append_Handler, + }, + { + MethodName: "Cursor", + Handler: _WitnessService_Cursor_Handler, + }, + { + MethodName: "Read", + Handler: _WitnessService_Read_Handler, + }, + { + MethodName: "Keys", + Handler: _WitnessService_Keys_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "saas/policylog/v1/witness.proto", +} diff --git a/module/services/policy-log/code/server/server.go b/module/services/policy-log/code/server/server.go new file mode 100644 index 000000000..fe4e2d0bc --- /dev/null +++ b/module/services/policy-log/code/server/server.go @@ -0,0 +1,206 @@ +// Package server is the gRPC projection of the witness. It owns the wire shape +// and the translation of the witness error taxonomy, and no policy of its own: +// validation, sequencing, signing and the five-second budget all stay behind +// the transport-independent service boundary. +package server + +import ( + "context" + "errors" + "fmt" + + policylogv1 "policy-log/pkg/gen/saas/policylog/v1" + "policy-log/service" + "policy-log/witness" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Witness is the boundary this server projects. It is an interface so that each +// arm of the error taxonomy can be exercised exactly, rather than approximated +// by corrupting a store; *service.Service is the only implementation that ships. +type Witness interface { + Append(context.Context, witness.Entry) (*witness.Receipt, error) + Cursor(context.Context) (*witness.Cursor, error) + Read(context.Context, uint64, uint32) ([]witness.Record, error) + Keys() witness.Key +} + +// The production implementation must keep satisfying the boundary. +var _ Witness = (*service.Service)(nil) + +type Server struct { + policylogv1.UnimplementedWitnessServiceServer + witness Witness + failed func(error) +} + +// New requires a failure reporter because an error outside the taxonomy is +// deliberately not echoed to the caller: a store error can carry bucket +// identity and endpoint detail, and the caller's correct behaviour does not +// depend on it. The detail goes to this service's own log instead. +func New(w Witness, failed func(error)) (*Server, error) { + if w == nil || failed == nil { + return nil, errors.New("policy-log: witness boundary and failure reporter are required") + } + return &Server{witness: w, failed: failed}, nil +} + +// unreachable is the only thing a caller learns about an error the taxonomy does +// not name. It says what the caller needs: no sequence was assigned and no +// receipt exists, so the identical operation may be retried. +const unreachable = "policy log could not reach its authority store; no sequence was assigned and no receipt was issued" + +// statusFor maps the witness taxonomy onto gRPC codes. The five taxonomy errors +// are NOT interchangeable and must never collapse into one code: +// +// - ErrInvalid -> InvalidArgument. The request is malformed. Retrying +// the same bytes can never succeed. +// - ErrConflict -> AlreadyExists. The operation id already names a +// DIFFERENT entry. Permanent: the caller +// must not retry, and must not rename the +// operation to get past it. +// - ErrPending -> FailedPrecondition. Another operation holds the next +// sequence uncommitted. Only an explicit +// retry of THAT operation can clear it, so +// this caller must not retry until the log's +// state is repaired. Distinct from +// ErrConflict because this one does clear. +// - ErrRollback -> Aborted. The head moved backwards, changed a +// known hash, or the caller is ahead of the +// log. A sequencer check failed; the caller +// must stop serving the authority it holds, +// not retry. +// - ErrIntegrity -> DataLoss. The log's own stored evidence does +// not verify. Unrecoverable corruption, +// distinct from a regression. +// +// Cancellation and the deadline keep their own codes, because both leave the +// outcome genuinely ambiguous: a storage request already in flight can have +// committed. The caller reconciles by retrying the identical operation, whose +// receipt the head will return if it did. +func (s *Server) statusFor(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, context.Canceled): + return status.Error(codes.Canceled, err.Error()) + case errors.Is(err, context.DeadlineExceeded): + return status.Error(codes.DeadlineExceeded, err.Error()) + case errors.Is(err, witness.ErrInvalid): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, witness.ErrConflict): + return status.Error(codes.AlreadyExists, err.Error()) + case errors.Is(err, witness.ErrPending): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, witness.ErrRollback): + return status.Error(codes.Aborted, err.Error()) + case errors.Is(err, witness.ErrIntegrity): + return status.Error(codes.DataLoss, err.Error()) + default: + s.failed(err) + return status.Error(codes.Unavailable, unreachable) + } +} + +// entryFrom carries the wire entry into the contract without inventing any part +// of it. +// +// A missing or out-of-range requested_at is refused here rather than passed on: +// AsTime of an absent Timestamp is 1970-01-01, which Entry.Validate accepts, so +// the log would sign an entry hash over a timestamp the caller never sent — and +// no retry of that operation could ever reproduce it. +func entryFrom(wire *policylogv1.Entry) (witness.Entry, error) { + if wire == nil { + return witness.Entry{}, fmt.Errorf("%w: entry is absent", witness.ErrInvalid) + } + if err := wire.GetRequestedAt().CheckValid(); err != nil { + return witness.Entry{}, fmt.Errorf("%w: requested_at is required and must be a valid timestamp", witness.ErrInvalid) + } + return witness.Entry{ + OperationID: wire.GetOperationId(), + Kind: wire.GetKind(), + Subject: wire.GetSubject(), + Tenant: wire.GetTenant(), + NarrowingHash: wire.GetNarrowingHash(), + RequestedAt: wire.GetRequestedAt().AsTime(), + }, nil +} + +// entryTo and receiptTo carry raw digests as bytes and the timestamps as +// seconds and nanoseconds, which is exactly what the canonical encodings hash. +// A reader can therefore recompute entry_hash from the wire entry it received. +func entryTo(entry witness.Entry) *policylogv1.Entry { + return &policylogv1.Entry{ + OperationId: entry.OperationID, + Kind: entry.Kind, + Subject: entry.Subject, + Tenant: entry.Tenant, + NarrowingHash: entry.NarrowingHash, + RequestedAt: timestamppb.New(entry.RequestedAt), + } +} + +func receiptTo(receipt witness.Receipt) *policylogv1.Receipt { + return &policylogv1.Receipt{ + Seq: receipt.Seq, + EntryHash: receipt.EntryHash, + PrevHash: receipt.PrevHash, + ChainHash: receipt.ChainHash, + IssuedAt: timestamppb.New(receipt.IssuedAt), + Signature: receipt.Signature, + } +} + +func (s *Server) Append(ctx context.Context, request *policylogv1.AppendRequest) (*policylogv1.AppendResponse, error) { + entry, err := entryFrom(request.GetEntry()) + if err != nil { + return nil, s.statusFor(err) + } + receipt, err := s.witness.Append(ctx, entry) + if err != nil { + return nil, s.statusFor(err) + } + return &policylogv1.AppendResponse{Receipt: receiptTo(*receipt)}, nil +} + +func (s *Server) Cursor(ctx context.Context, _ *policylogv1.CursorRequest) (*policylogv1.CursorResponse, error) { + cursor, err := s.witness.Cursor(ctx) + if err != nil { + return nil, s.statusFor(err) + } + return &policylogv1.CursorResponse{Cursor: &policylogv1.Cursor{ + HeadSeq: cursor.HeadSeq, + ChainHash: cursor.ChainHash, + IssuedAt: timestamppb.New(cursor.IssuedAt), + Signature: cursor.Signature, + }}, nil +} + +func (s *Server) Read(ctx context.Context, request *policylogv1.ReadRequest) (*policylogv1.ReadResponse, error) { + records, err := s.witness.Read(ctx, request.GetFromSeq(), request.GetLimit()) + if err != nil { + return nil, s.statusFor(err) + } + // An empty page is a page: reading at head+1 returns one, and a nil slice + // here must not read as a different answer from an explicitly empty list. + response := &policylogv1.ReadResponse{Records: make([]*policylogv1.Record, 0, len(records))} + for _, record := range records { + response.Records = append(response.Records, &policylogv1.Record{ + Entry: entryTo(record.Entry), + Receipt: receiptTo(record.Receipt), + }) + } + return response, nil +} + +func (s *Server) Keys(_ context.Context, _ *policylogv1.KeysRequest) (*policylogv1.KeysResponse, error) { + key := s.witness.Keys() + return &policylogv1.KeysResponse{Key: &policylogv1.Key{ + Algorithm: key.Algorithm, + KeyId: key.KeyID, + PublicKey: key.PublicKey, + }}, nil +} diff --git a/module/services/policy-log/code/server/server_test.go b/module/services/policy-log/code/server/server_test.go new file mode 100644 index 000000000..e55b79b65 --- /dev/null +++ b/module/services/policy-log/code/server/server_test.go @@ -0,0 +1,365 @@ +package server + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "errors" + "fmt" + "testing" + "time" + + policylogv1 "policy-log/pkg/gen/saas/policylog/v1" + "policy-log/witness" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// stub answers each operation with whatever one case needs. The error taxonomy +// is the thing under test, so it must be injectable directly rather than +// provoked by corrupting a store, which can only produce some of the arms. +type stub struct { + err error + receipt *witness.Receipt + cursor *witness.Cursor + records []witness.Record + key witness.Key + seen witness.Entry + calls int +} + +func (s *stub) Append(_ context.Context, entry witness.Entry) (*witness.Receipt, error) { + s.calls++ + s.seen = entry + if s.err != nil { + return nil, s.err + } + return s.receipt, nil +} + +func (s *stub) Cursor(context.Context) (*witness.Cursor, error) { + if s.err != nil { + return nil, s.err + } + return s.cursor, nil +} + +func (s *stub) Read(_ context.Context, _ uint64, _ uint32) ([]witness.Record, error) { + if s.err != nil { + return nil, s.err + } + return s.records, nil +} + +func (s *stub) Keys() witness.Key { return s.key } + +func newServer(t *testing.T, w Witness) (*Server, *[]error) { + t.Helper() + reported := new([]error) + s, err := New(w, func(err error) { *reported = append(*reported, err) }) + if err != nil { + t.Fatalf("New: %v", err) + } + return s, reported +} + +func digest(b byte) []byte { + h := make([]byte, sha256.Size) + for i := range h { + h[i] = b + } + return h +} + +func wireEntry() *policylogv1.Entry { + return &policylogv1.Entry{ + OperationId: "operation-1", + Kind: "binding", + Subject: "subject-1", + Tenant: "tenant-1", + NarrowingHash: digest(0x11), + RequestedAt: timestamppb.New(time.Unix(1700000000, 123456789).UTC()), + } +} + +// TestAppendMapsEachTaxonomyErrorToItsOwnCode is the point of the mapping: a +// retrying caller reads the code, and ErrConflict (permanent) and ErrPending +// (clears when the original operation is retried) must never arrive as the +// same answer. Collapsing any pair here makes a retryable state look permanent +// or the reverse. +func TestAppendMapsEachTaxonomyErrorToItsOwnCode(t *testing.T) { + cases := []struct { + name string + // arm is the condition this case stands for. Two cases sharing a code + // are only acceptable when they stand for the same arm, which is how a + // wrapped sentinel differs from a second, distinct condition. + arm error + err error + want codes.Code + }{ + {"invalid", witness.ErrInvalid, witness.ErrInvalid, codes.InvalidArgument}, + {"conflict", witness.ErrConflict, witness.ErrConflict, codes.AlreadyExists}, + {"pending", witness.ErrPending, witness.ErrPending, codes.FailedPrecondition}, + {"rollback", witness.ErrRollback, witness.ErrRollback, codes.Aborted}, + {"integrity", witness.ErrIntegrity, witness.ErrIntegrity, codes.DataLoss}, + {"canceled", context.Canceled, context.Canceled, codes.Canceled}, + {"deadline", context.DeadlineExceeded, context.DeadlineExceeded, codes.DeadlineExceeded}, + // Wrapped, which is how the witness actually returns most of them. + {"wrapped invalid", witness.ErrInvalid, fmt.Errorf("%w: identifier", witness.ErrInvalid), codes.InvalidArgument}, + {"wrapped integrity", witness.ErrIntegrity, fmt.Errorf("%w: sequence 7", witness.ErrIntegrity), codes.DataLoss}, + {"wrapped rollback", witness.ErrRollback, fmt.Errorf("read committed sequence 3: %w", witness.ErrRollback), codes.Aborted}, + } + seen := make(map[codes.Code]error) + for _, tc := range cases { + s, reported := newServer(t, &stub{err: tc.err}) + _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wireEntry()}) + if got := status.Code(err); got != tc.want { + t.Errorf("%s: Append returned code %v, want %v (err %v)", tc.name, got, tc.want, err) + } + if len(*reported) != 0 { + t.Errorf("%s: a taxonomy error must not be reported as an unreachable store: %v", tc.name, *reported) + } + if previous, ok := seen[tc.want]; ok && previous != tc.arm { + t.Errorf("%q and %q are different conditions that both map to %v; the taxonomy arms must stay distinguishable", previous, tc.arm, tc.want) + } + seen[tc.want] = tc.arm + } + // Every arm of the taxonomy must be covered, so adding a sentinel to + // witness/contract.go without mapping it fails here instead of silently + // arriving as Unavailable. + for _, sentinel := range []error{witness.ErrInvalid, witness.ErrConflict, witness.ErrPending, witness.ErrRollback, witness.ErrIntegrity} { + covered := false + for _, tc := range cases { + if tc.arm == sentinel { + covered = true + } + } + if !covered { + t.Errorf("witness taxonomy error %v has no mapping case", sentinel) + } + } +} + +// TestAppendWithholdsStoreDetailAndReportsIt covers the one error class that is +// NOT in the taxonomy: a store failure. The caller learns only that no sequence +// was assigned, and the detail — which can carry bucket identity — goes to the +// service's own reporter. +func TestAppendWithholdsStoreDetailAndReportsIt(t *testing.T) { + underlying := errors.New("dial gs://example-witness-bucket: connection refused") + s, reported := newServer(t, &stub{err: underlying}) + _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wireEntry()}) + if got := status.Code(err); got != codes.Unavailable { + t.Fatalf("Append returned code %v, want %v", got, codes.Unavailable) + } + if message := status.Convert(err).Message(); message != unreachable { + t.Errorf("Append message = %q, want the fixed unreachable message", message) + } + if len(*reported) != 1 || !errors.Is((*reported)[0], underlying) { + t.Fatalf("the underlying error must reach the reporter exactly once, got %v", *reported) + } +} + +// TestAppendRefusesAnAbsentRequestedAtInsteadOfSigning1970 is the trap the +// projection has to close. AsTime of an absent Timestamp is 1970-01-01, which +// Entry.Validate accepts, so without this check the witness would sign an entry +// hash over a timestamp the caller never sent — and no retry of that operation +// could reproduce it. +func TestAppendRefusesAnAbsentRequestedAtInsteadOfSigning1970(t *testing.T) { + for _, tc := range []struct { + name string + entry *policylogv1.Entry + }{ + {"absent requested_at", &policylogv1.Entry{ + OperationId: "operation-1", Kind: "binding", Subject: "subject-1", + NarrowingHash: digest(0x11), + }}, + {"out of range requested_at", &policylogv1.Entry{ + OperationId: "operation-1", Kind: "binding", Subject: "subject-1", + NarrowingHash: digest(0x11), + RequestedAt: ×tamppb.Timestamp{Seconds: 1, Nanos: -5}, + }}, + {"absent entry", nil}, + } { + backend := &stub{receipt: &witness.Receipt{Seq: 1}} + s, _ := newServer(t, backend) + _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: tc.entry}) + if got := status.Code(err); got != codes.InvalidArgument { + t.Errorf("%s: Append returned code %v, want %v", tc.name, got, codes.InvalidArgument) + } + if backend.calls != 0 { + t.Errorf("%s: the witness must not be reached at all, got %d calls", tc.name, backend.calls) + } + } +} + +// TestAppendCarriesTheEntryLosslessly is the contract the proto exists to keep: +// the entry hash computed from what the server handed the witness must equal the +// hash of the entry the caller described on the wire, including the exact +// nanosecond of requested_at and the raw 32 digest bytes. If it did not, a retry +// of the same operation would produce a different hash and be refused. +func TestAppendCarriesTheEntryLosslessly(t *testing.T) { + wire := wireEntry() + backend := &stub{receipt: &witness.Receipt{Seq: 1}} + s, _ := newServer(t, backend) + if _, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wire}); err != nil { + t.Fatalf("Append: %v", err) + } + want := witness.Entry{ + OperationID: wire.GetOperationId(), Kind: wire.GetKind(), Subject: wire.GetSubject(), + Tenant: wire.GetTenant(), NarrowingHash: wire.GetNarrowingHash(), + RequestedAt: time.Unix(1700000000, 123456789).UTC(), + } + wantHash, err := witness.EntryHash(want) + if err != nil { + t.Fatalf("EntryHash: %v", err) + } + gotHash, err := witness.EntryHash(backend.seen) + if err != nil { + t.Fatalf("EntryHash of the carried entry: %v", err) + } + if string(gotHash) != string(wantHash) { + t.Fatalf("the carried entry hashes differently from the wire entry:\n got %x\nwant %x\ncarried %+v", gotHash, wantHash, backend.seen) + } + // The round trip back out must reproduce the same bytes, so a reader can + // recompute the hash from a Read response. + round, err := entryFrom(entryTo(backend.seen)) + if err != nil { + t.Fatalf("entryFrom(entryTo(...)): %v", err) + } + roundHash, err := witness.EntryHash(round) + if err != nil { + t.Fatalf("EntryHash of the round trip: %v", err) + } + if string(roundHash) != string(wantHash) { + t.Fatalf("round trip hashes differently:\n got %x\nwant %x", roundHash, wantHash) + } +} + +// TestAppendProjectsTheWholeReceipt proves no field of the receipt is dropped: +// a caller that cannot see prev_hash or the signature cannot verify the chain, +// and a receipt it cannot verify is not evidence. +func TestAppendProjectsTheWholeReceipt(t *testing.T) { + issued := time.Unix(1700000001, 42).UTC() + receipt := &witness.Receipt{ + Seq: 9, EntryHash: digest(0x01), PrevHash: digest(0x02), ChainHash: digest(0x03), + IssuedAt: issued, Signature: make([]byte, ed25519.SignatureSize), + } + s, _ := newServer(t, &stub{receipt: receipt}) + response, err := s.Append(context.Background(), &policylogv1.AppendRequest{Entry: wireEntry()}) + if err != nil { + t.Fatalf("Append: %v", err) + } + got := response.GetReceipt() + if got.GetSeq() != 9 || + string(got.GetEntryHash()) != string(digest(0x01)) || + string(got.GetPrevHash()) != string(digest(0x02)) || + string(got.GetChainHash()) != string(digest(0x03)) || + len(got.GetSignature()) != ed25519.SignatureSize || + !got.GetIssuedAt().AsTime().Equal(issued) { + t.Fatalf("receipt projection lost a field: %+v", got) + } +} + +// TestCursorAndKeysProjectTheSignedObservation keeps the two discovery +// operations honest: an unsigned cursor or a key document without its id is not +// usable by a verifying client. +func TestCursorAndKeysProjectTheSignedObservation(t *testing.T) { + issued := time.Unix(1700000002, 7).UTC() + backend := &stub{ + cursor: &witness.Cursor{HeadSeq: 4, ChainHash: digest(0x05), IssuedAt: issued, Signature: make([]byte, ed25519.SignatureSize)}, + key: witness.Key{Algorithm: "Ed25519", KeyID: "abc123", PublicKey: make([]byte, ed25519.PublicKeySize)}, + } + s, _ := newServer(t, backend) + cursor, err := s.Cursor(context.Background(), &policylogv1.CursorRequest{}) + if err != nil { + t.Fatalf("Cursor: %v", err) + } + if cursor.GetCursor().GetHeadSeq() != 4 || + string(cursor.GetCursor().GetChainHash()) != string(digest(0x05)) || + len(cursor.GetCursor().GetSignature()) != ed25519.SignatureSize || + !cursor.GetCursor().GetIssuedAt().AsTime().Equal(issued) { + t.Fatalf("cursor projection lost a field: %+v", cursor.GetCursor()) + } + keys, err := s.Keys(context.Background(), &policylogv1.KeysRequest{}) + if err != nil { + t.Fatalf("Keys: %v", err) + } + if keys.GetKey().GetAlgorithm() != "Ed25519" || keys.GetKey().GetKeyId() != "abc123" || + len(keys.GetKey().GetPublicKey()) != ed25519.PublicKeySize { + t.Fatalf("key projection lost a field: %+v", keys.GetKey()) + } +} + +// TestReadReturnsAnEmptyPageRatherThanNothing matters because reading at +// head_seq + 1 is a legitimate empty page, and a reconciler must be able to +// tell it from a refusal. It must also carry each record's ORIGINAL receipt. +func TestReadReturnsAnEmptyPageRatherThanNothing(t *testing.T) { + s, _ := newServer(t, &stub{records: nil}) + empty, err := s.Read(context.Background(), &policylogv1.ReadRequest{FromSeq: 1, Limit: 10}) + if err != nil { + t.Fatalf("Read: %v", err) + } + if empty.GetRecords() == nil || len(empty.GetRecords()) != 0 { + t.Fatalf("an empty page must be an empty list, got %v", empty.GetRecords()) + } + + requested := time.Unix(1700000003, 321).UTC() + backend := &stub{records: []witness.Record{{ + Entry: witness.Entry{OperationID: "op-2", Kind: "scope_grant", Subject: "s", Tenant: "", NarrowingHash: digest(0x07), RequestedAt: requested}, + Receipt: witness.Receipt{Seq: 2, EntryHash: digest(0x08), PrevHash: digest(0x09), ChainHash: digest(0x0a), IssuedAt: requested, Signature: make([]byte, ed25519.SignatureSize)}, + }}} + s, _ = newServer(t, backend) + page, err := s.Read(context.Background(), &policylogv1.ReadRequest{FromSeq: 2, Limit: 1}) + if err != nil { + t.Fatalf("Read: %v", err) + } + if len(page.GetRecords()) != 1 { + t.Fatalf("Read returned %d records, want 1", len(page.GetRecords())) + } + record := page.GetRecords()[0] + if record.GetEntry().GetOperationId() != "op-2" || record.GetReceipt().GetSeq() != 2 || + string(record.GetEntry().GetNarrowingHash()) != string(digest(0x07)) || + !record.GetEntry().GetRequestedAt().AsTime().Equal(requested) || + len(record.GetReceipt().GetSignature()) != ed25519.SignatureSize { + t.Fatalf("record projection lost a field: %+v", record) + } +} + +// TestReadPassesTheBoundsThroughUnchanged: the limit ceiling and the inclusive +// floor are the witness's to enforce, and a transport that clamped or defaulted +// them would turn a refusal into a silently different page. +func TestReadPassesTheBoundsThroughUnchanged(t *testing.T) { + var gotFrom uint64 + var gotLimit uint32 + s, _ := newServer(t, &boundsStub{from: &gotFrom, limit: &gotLimit}) + if _, err := s.Read(context.Background(), &policylogv1.ReadRequest{FromSeq: 0, Limit: witness.MaxReadLimit + 1}); err != nil { + t.Fatalf("Read: %v", err) + } + if gotFrom != 0 || gotLimit != witness.MaxReadLimit+1 { + t.Fatalf("Read passed from=%d limit=%d, want them unchanged (0, %d)", gotFrom, gotLimit, witness.MaxReadLimit+1) + } +} + +type boundsStub struct { + stub + from *uint64 + limit *uint32 +} + +func (b *boundsStub) Read(_ context.Context, from uint64, limit uint32) ([]witness.Record, error) { + *b.from, *b.limit = from, limit + return nil, nil +} + +// TestNewRefusesAnIncompleteConstruction keeps the server from being built +// without a reporter, which is what keeps a withheld store error observable. +func TestNewRefusesAnIncompleteConstruction(t *testing.T) { + if _, err := New(nil, func(error) {}); err == nil { + t.Error("New accepted a nil witness") + } + if _, err := New(&stub{}, nil); err == nil { + t.Error("New accepted a nil failure reporter") + } +} diff --git a/module/services/policy-log/code/server/transport_test.go b/module/services/policy-log/code/server/transport_test.go new file mode 100644 index 000000000..4bfd97017 --- /dev/null +++ b/module/services/policy-log/code/server/transport_test.go @@ -0,0 +1,380 @@ +package server_test + +// These exercise the projection over a REAL gRPC connection, a real +// service.Service, a real witness and the generated client — not a stub. The +// stubbed tests in the package next door prove the mapping of each taxonomy +// arm; these prove the arms actually arise from the shipped code and survive +// the wire, which is the part a hand-written adapter gets wrong. + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "net" + "testing" + "time" + + "policy-log/mirror" + "policy-log/objectstore" + "policy-log/objectstore/memory" + policylogv1 "policy-log/pkg/gen/saas/policylog/v1" + "policy-log/server" + "policy-log/service" + "policy-log/witness" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type discard struct{} + +func (discard) Write(context.Context, witness.Record) error { return nil } + +func genesisStore(t *testing.T) *memory.Store { + t.Helper() + store := memory.New() + genesis, err := json.Marshal(struct { + Seq uint64 `json:"seq"` + ChainHash []byte `json:"chain_hash"` + }{ChainHash: make([]byte, sha256.Size)}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Write(context.Background(), witness.HeadObject, genesis, 0); err != nil { + t.Fatal(err) + } + return store +} + +// serve stands up the whole shipped stack — witness, service, projection — on a +// loopback listener and returns the generated client for it. No fixed port is +// written here any more than one is written in the binary. +func serve(t *testing.T, objects objectstore.Store, key ed25519.PrivateKey) policylogv1.WitnessServiceClient { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + log, err := witness.Open(ctx, objects, key) + if err != nil { + t.Fatal(err) + } + worker, err := mirror.Start(ctx, discard{}, func(error) {}) + if err != nil { + t.Fatal(err) + } + witnessService, err := service.New(log, worker) + if err != nil { + t.Fatal(err) + } + handler, err := server.New(witnessService, func(error) {}) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + grpcServer := grpc.NewServer() + policylogv1.RegisterWitnessServiceServer(grpcServer, handler) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(grpcServer.Stop) + + connection, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = connection.Close() }) + return policylogv1.NewWitnessServiceClient(connection) +} + +func dial(t *testing.T) (policylogv1.WitnessServiceClient, ed25519.PublicKey) { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return serve(t, genesisStore(t), private), public +} + +// headFailure persists an entry object and then loses the head write, which is +// the interrupted append the ErrPending arm exists for. +type headFailure struct{ objectstore.Store } + +func (s headFailure) Write(ctx context.Context, name string, body []byte, generation int64) (int64, error) { + if name == witness.HeadObject { + return 0, objectstore.ErrPrecondition + } + return s.Store.Write(ctx, name, body, generation) +} + +func digest(b byte) []byte { + h := make([]byte, sha256.Size) + for i := range h { + h[i] = b + } + return h +} + +func entry(operation string, hash byte, requested time.Time) *policylogv1.Entry { + return &policylogv1.Entry{ + OperationId: operation, + Kind: "binding", + Subject: "subject-1", + Tenant: "tenant-1", + NarrowingHash: digest(hash), + RequestedAt: timestamppb.New(requested), + } +} + +func receiptFrom(wire *policylogv1.Receipt) witness.Receipt { + return witness.Receipt{ + Seq: wire.GetSeq(), EntryHash: wire.GetEntryHash(), PrevHash: wire.GetPrevHash(), + ChainHash: wire.GetChainHash(), IssuedAt: wire.GetIssuedAt().AsTime(), Signature: wire.GetSignature(), + } +} + +// TestAppendedReceiptVerifiesUnderThePublishedKey is the whole point of the +// transport: what comes back over the wire must verify under the key Keys +// publishes, and its entry hash must be the hash of the bytes the caller asked +// to append. A projection that lost a byte of any digest would pass a shape +// check and fail this. +func TestAppendedReceiptVerifiesUnderThePublishedKey(t *testing.T) { + client, public := dial(t) + ctx := context.Background() + requested := time.Unix(1700000000, 123456789).UTC() + + appended, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) + if err != nil { + t.Fatalf("Append: %v", err) + } + receipt := receiptFrom(appended.GetReceipt()) + if receipt.Seq != 1 { + t.Fatalf("first sequence = %d, want 1", receipt.Seq) + } + if !witness.VerifyReceipt(public, receipt) { + t.Fatal("the receipt that crossed the wire does not verify under the published key") + } + wantHash, err := witness.EntryHash(witness.Entry{ + OperationID: "operation-1", Kind: "binding", Subject: "subject-1", Tenant: "tenant-1", + NarrowingHash: digest(0x11), RequestedAt: requested, + }) + if err != nil { + t.Fatal(err) + } + if string(receipt.EntryHash) != string(wantHash) { + t.Fatalf("receipt entry hash %x does not match the appended bytes %x", receipt.EntryHash, wantHash) + } + + keys, err := client.Keys(ctx, &policylogv1.KeysRequest{}) + if err != nil { + t.Fatalf("Keys: %v", err) + } + if string(keys.GetKey().GetPublicKey()) != string(public) { + t.Fatal("Keys published a different public key than the one the log signs with") + } +} + +// TestRetryOfTheIdenticalEntryReturnsTheOriginalReceipt proves the idempotency +// the whole protocol rests on survives the projection — including requested_at, +// which is covered by the entry hash, so a transport that rounded it would turn +// every retry into a conflict. +func TestRetryOfTheIdenticalEntryReturnsTheOriginalReceipt(t *testing.T) { + client, _ := dial(t) + ctx := context.Background() + requested := time.Unix(1700000000, 987654321).UTC() + + first, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) + if err != nil { + t.Fatalf("first Append: %v", err) + } + second, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) + if err != nil { + t.Fatalf("retried Append: %v", err) + } + if first.GetReceipt().GetSeq() != second.GetReceipt().GetSeq() || + string(first.GetReceipt().GetSignature()) != string(second.GetReceipt().GetSignature()) { + t.Fatalf("a retry minted a new receipt: %v then %v", first.GetReceipt(), second.GetReceipt()) + } +} + +// TestReusedOperationIDWithDifferentBytesIsAlreadyExists: the conflict arm, +// raised by the shipped witness rather than injected. +func TestReusedOperationIDWithDifferentBytesIsAlreadyExists(t *testing.T) { + client, _ := dial(t) + ctx := context.Background() + requested := time.Unix(1700000000, 0).UTC() + if _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}); err != nil { + t.Fatalf("Append: %v", err) + } + _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x22, requested)}) + if got := status.Code(err); got != codes.AlreadyExists { + t.Fatalf("reusing an operation id with different bytes returned %v, want %v (err %v)", got, codes.AlreadyExists, err) + } +} + +// TestAnInvalidEntryIsRefusedWithoutASequence: the invalid arm, and the proof +// that a refusal consumes nothing — the next valid append is still sequence 1. +func TestAnInvalidEntryIsRefusedWithoutASequence(t *testing.T) { + client, _ := dial(t) + ctx := context.Background() + short := entry("operation-1", 0x11, time.Unix(1700000000, 0).UTC()) + short.NarrowingHash = []byte{0x11} + _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: short}) + if got := status.Code(err); got != codes.InvalidArgument { + t.Fatalf("a 1-byte narrowing hash returned %v, want %v (err %v)", got, codes.InvalidArgument, err) + } + good, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-2", 0x11, time.Unix(1700000000, 0).UTC())}) + if err != nil { + t.Fatalf("Append after a refusal: %v", err) + } + if good.GetReceipt().GetSeq() != 1 { + t.Fatalf("a refused append consumed sequence 1; next append got %d", good.GetReceipt().GetSeq()) + } +} + +// TestReadAtTheHeadIsEmptyAndBeyondItIsAborted is the distinction the README +// calls out: an ahead-of-log reconciler must not be able to mistake a rollback +// for inactivity, so head+1 is an empty page and head+2 is a refusal. +func TestReadAtTheHeadIsEmptyAndBeyondItIsAborted(t *testing.T) { + client, public := dial(t) + ctx := context.Background() + requested := time.Unix(1700000000, 5).UTC() + if _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}); err != nil { + t.Fatalf("Append: %v", err) + } + + page, err := client.Read(ctx, &policylogv1.ReadRequest{FromSeq: 1, Limit: witness.MaxReadLimit}) + if err != nil { + t.Fatalf("Read: %v", err) + } + if len(page.GetRecords()) != 1 { + t.Fatalf("Read(1) returned %d records, want 1", len(page.GetRecords())) + } + record := page.GetRecords()[0] + if !witness.VerifyReceipt(public, receiptFrom(record.GetReceipt())) { + t.Fatal("a read record's original receipt does not verify") + } + if !record.GetEntry().GetRequestedAt().AsTime().Equal(requested) { + t.Fatalf("read requested_at = %v, want %v", record.GetEntry().GetRequestedAt().AsTime(), requested) + } + + atHead, err := client.Read(ctx, &policylogv1.ReadRequest{FromSeq: 2, Limit: 1}) + if err != nil { + t.Fatalf("Read at head+1 must be an empty page, got %v", err) + } + if len(atHead.GetRecords()) != 0 { + t.Fatalf("Read at head+1 returned %d records, want 0", len(atHead.GetRecords())) + } + + _, err = client.Read(ctx, &policylogv1.ReadRequest{FromSeq: 3, Limit: 1}) + if got := status.Code(err); got != codes.Aborted { + t.Fatalf("Read beyond head+1 returned %v, want %v (err %v)", got, codes.Aborted, err) + } +} + +// TestReadBoundsAreRefusedNotClamped: from_seq 0 and an over-ceiling limit are +// invalid requests, not requests to be quietly adjusted. +func TestReadBoundsAreRefusedNotClamped(t *testing.T) { + client, _ := dial(t) + ctx := context.Background() + for _, tc := range []struct { + name string + request *policylogv1.ReadRequest + }{ + {"from_seq 0", &policylogv1.ReadRequest{FromSeq: 0, Limit: 1}}, + {"limit 0", &policylogv1.ReadRequest{FromSeq: 1, Limit: 0}}, + {"limit over the ceiling", &policylogv1.ReadRequest{FromSeq: 1, Limit: witness.MaxReadLimit + 1}}, + } { + _, err := client.Read(ctx, tc.request) + if got := status.Code(err); got != codes.InvalidArgument { + t.Errorf("%s returned %v, want %v (err %v)", tc.name, got, codes.InvalidArgument, err) + } + } +} + +// TestCursorVerifiesAndTracksTheHead keeps the signed observation usable: an +// unsigned or stale-shaped cursor is not something a client can pin against. +func TestCursorVerifiesAndTracksTheHead(t *testing.T) { + client, public := dial(t) + ctx := context.Background() + + empty, err := client.Cursor(ctx, &policylogv1.CursorRequest{}) + if err != nil { + t.Fatalf("Cursor: %v", err) + } + genesis := witness.Cursor{ + HeadSeq: empty.GetCursor().GetHeadSeq(), ChainHash: empty.GetCursor().GetChainHash(), + IssuedAt: empty.GetCursor().GetIssuedAt().AsTime(), Signature: empty.GetCursor().GetSignature(), + } + if genesis.HeadSeq != 0 || !witness.VerifyCursor(public, genesis) { + t.Fatalf("the genesis cursor does not verify: %+v", genesis) + } + + if _, err := client.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, time.Unix(1700000000, 0).UTC())}); err != nil { + t.Fatalf("Append: %v", err) + } + advanced, err := client.Cursor(ctx, &policylogv1.CursorRequest{}) + if err != nil { + t.Fatalf("Cursor: %v", err) + } + moved := witness.Cursor{ + HeadSeq: advanced.GetCursor().GetHeadSeq(), ChainHash: advanced.GetCursor().GetChainHash(), + IssuedAt: advanced.GetCursor().GetIssuedAt().AsTime(), Signature: advanced.GetCursor().GetSignature(), + } + if moved.HeadSeq != 1 || !witness.VerifyCursor(public, moved) { + t.Fatalf("the cursor after one append does not verify at sequence 1: %+v", moved) + } +} + +// TestAnInterruptedAppendIsFailedPreconditionUntilItsOwnRetry is the arm that +// must never read like ErrConflict. An append that persisted its immutable +// entry and then lost the head write reserves that sequence: a DIFFERENT +// operation is refused with FailedPrecondition, which clears — and only the +// identical operation can finish it. A caller told AlreadyExists here would +// give up on a state that was about to resolve; a caller told nothing would +// mint a second sequence for one narrowing. +func TestAnInterruptedAppendIsFailedPreconditionUntilItsOwnRetry(t *testing.T) { + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + store := genesisStore(t) + ctx := context.Background() + requested := time.Unix(1700000000, 11).UTC() + + // The same log, reached once through a store that loses the head write and + // once through the healthy one, which is how an interrupted append is + // observed after a restart. + interrupted := serve(t, headFailure{store}, private) + healthy := serve(t, store, private) + + _, err = interrupted.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) + if got := status.Code(err); got != codes.Unavailable { + t.Fatalf("an append whose head write is lost returned %v, want %v (err %v)", got, codes.Unavailable, err) + } + + _, err = healthy.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-2", 0x22, requested)}) + if got := status.Code(err); got != codes.FailedPrecondition { + t.Fatalf("a different operation over the reserved sequence returned %v, want %v (err %v)", got, codes.FailedPrecondition, err) + } + + finished, err := healthy.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x11, requested)}) + if err != nil { + t.Fatalf("the identical operation must be able to finish its own reserved sequence: %v", err) + } + if finished.GetReceipt().GetSeq() != 1 { + t.Fatalf("the finished retry got sequence %d, want the reserved 1", finished.GetReceipt().GetSeq()) + } + + // And the same operation id with different bytes is still the permanent + // answer, so the two arms stay distinguishable end to end. + _, err = healthy.Append(ctx, &policylogv1.AppendRequest{Entry: entry("operation-1", 0x33, requested)}) + if got := status.Code(err); got != codes.AlreadyExists { + t.Fatalf("reusing the committed operation id with different bytes returned %v, want %v (err %v)", got, codes.AlreadyExists, err) + } +} diff --git a/module/services/policy-log/code/service/service.go b/module/services/policy-log/code/service/service.go new file mode 100644 index 000000000..638777d36 --- /dev/null +++ b/module/services/policy-log/code/service/service.go @@ -0,0 +1,55 @@ +// Package service joins the witness and its best-effort warehouse mirror. +// Transport adapters must use this boundary rather than offering candidates to +// the mirror themselves: only a committed Append result may be mirrored. +package service + +import ( + "bytes" + "context" + "errors" + + "policy-log/witness" +) + +type Service struct { + log *witness.Log + mirror Mirror +} + +// Mirror must offer without blocking; the production mirror.Worker has a +// bounded queue and drops a full queue instead of waiting on warehouse I/O. +type Mirror interface{ Offer(witness.Record) } + +func New(log *witness.Log, worker Mirror) (*Service, error) { + if log == nil || worker == nil { + return nil, errors.New("policy-log: witness and warehouse mirror are required") + } + return &Service{log: log, mirror: worker}, nil +} + +func (s *Service) Append(ctx context.Context, entry witness.Entry) (*witness.Receipt, error) { + ctx, cancel := context.WithTimeout(ctx, witness.AppendDeadline) + defer cancel() + if err := ctx.Err(); err != nil { + return nil, err + } + if err := entry.Validate(); err != nil { + return nil, err + } + entry.NarrowingHash = bytes.Clone(entry.NarrowingHash) + receipt, err := s.log.Append(ctx, entry) + if err != nil { + return nil, err + } + s.mirror.Offer(witness.Record{Entry: entry, Receipt: *receipt}) + if err := ctx.Err(); err != nil { + return nil, err + } + return receipt, nil +} + +func (s *Service) Cursor(ctx context.Context) (*witness.Cursor, error) { return s.log.Cursor(ctx) } +func (s *Service) Read(ctx context.Context, fromSeq uint64, limit uint32) ([]witness.Record, error) { + return s.log.Read(ctx, fromSeq, limit) +} +func (s *Service) Keys() witness.Key { return s.log.Keys() } diff --git a/module/services/policy-log/code/service/service_test.go b/module/services/policy-log/code/service/service_test.go new file mode 100644 index 000000000..e495b2a69 --- /dev/null +++ b/module/services/policy-log/code/service/service_test.go @@ -0,0 +1,151 @@ +package service + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "errors" + "testing" + "time" + + "policy-log/mirror" + "policy-log/objectstore" + "policy-log/objectstore/memory" + "policy-log/witness" +) + +type sinkFunc func(context.Context, witness.Record) error + +func (f sinkFunc) Write(ctx context.Context, r witness.Record) error { return f(ctx, r) } + +type headFailure struct{ objectstore.Store } + +type countingMirror struct{ offers int } + +func (m *countingMirror) Offer(witness.Record) { m.offers++ } + +func (s headFailure) Write(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if name == witness.HeadObject { + return 0, objectstore.ErrPrecondition + } + return s.Store.Write(ctx, name, b, g) +} + +func newService(t *testing.T, fail bool, sink mirror.Sink) (*Service, *memory.Store, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + store := memory.New() + genesis, _ := json.Marshal(struct { + Seq uint64 `json:"seq"` + ChainHash []byte `json:"chain_hash"` + }{ChainHash: make([]byte, 32)}) + if _, err := store.Write(ctx, witness.HeadObject, genesis, 0); err != nil { + t.Fatal(err) + } + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + var objects objectstore.Store = store + if fail { + objects = headFailure{store} + } + log, err := witness.Open(ctx, objects, key) + if err != nil { + t.Fatal(err) + } + worker, err := mirror.Start(ctx, sink, func(error) {}) + if err != nil { + t.Fatal(err) + } + s, err := New(log, worker) + if err != nil { + t.Fatal(err) + } + return s, store, cancel +} + +func operation() witness.Entry { + h := sha256.Sum256([]byte("narrowing bytes")) + return witness.Entry{OperationID: "example-operation", Kind: "narrowed", Subject: "example-principal", Tenant: "example-tenant", NarrowingHash: h[:], RequestedAt: time.Now().UTC()} +} + +func TestCommittedEntryIsMirroredWithTheSameReceipt(t *testing.T) { + mirrored := make(chan witness.Record, 1) + s, _, _ := newService(t, false, sinkFunc(func(_ context.Context, r witness.Record) error { mirrored <- r; return nil })) + e := operation() + r, err := s.Append(context.Background(), e) + if err != nil { + t.Fatal(err) + } + select { + case row := <-mirrored: + if row.Entry.OperationID != e.OperationID || row.Receipt.Seq != r.Seq || !ed25519.Verify(s.Keys().PublicKey, witness.ReceiptBytes(row.Receipt), row.Receipt.Signature) { + t.Fatal("mirror did not receive the committed signed record") + } + cursor, err := s.Cursor(context.Background()) + if err != nil || cursor.HeadSeq != row.Receipt.Seq { + t.Fatalf("mirrored before head commit: %v, %v", cursor, err) + } + case <-time.After(time.Second): + t.Fatal("committed entry was not offered to warehouse") + } +} + +func TestFailedHeadCommitNeverOffersWarehouseRecord(t *testing.T) { + s, _, _ := newService(t, true, sinkFunc(func(context.Context, witness.Record) error { return nil })) + mirrored := &countingMirror{} + s.mirror = mirrored + r, err := s.Append(context.Background(), operation()) + if r != nil || !errors.Is(err, objectstore.ErrPrecondition) { + t.Fatalf("head loss returned receipt: %v, %v", r, err) + } + if mirrored.offers != 0 { + t.Fatal("uncommitted entry offered to warehouse") + } +} + +func TestWarehouseFailureCannotWithholdOrMintAReceipt(t *testing.T) { + entered := make(chan struct{}) + s, _, _ := newService(t, false, sinkFunc(func(ctx context.Context, _ witness.Record) error { + close(entered) + <-ctx.Done() + return errors.New("warehouse unavailable") + })) + start := time.Now() + r, err := s.Append(context.Background(), operation()) + if err != nil || r == nil || time.Since(start) > time.Second || !witness.VerifyReceipt(s.Keys().PublicKey, *r) { + t.Fatalf("warehouse controlled receipt: %v, %v", r, err) + } + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("mirror was never attempted") + } +} + +func TestExpiredAndInvalidRequestsNeverReachMirror(t *testing.T) { + s, _, _ := newService(t, false, sinkFunc(func(context.Context, witness.Record) error { return nil })) + mirrored := &countingMirror{} + s.mirror = mirrored + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + if receipt, err := s.Append(ctx, operation()); receipt != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expired request: %v, %v", receipt, err) + } + bad := operation() + bad.NarrowingHash = make([]byte, objectstore.MaxObjectBytes+1) + if receipt, err := s.Append(context.Background(), bad); receipt != nil || !errors.Is(err, witness.ErrInvalid) { + t.Fatalf("oversized hash: %v, %v", receipt, err) + } + if mirrored.offers != 0 { + t.Fatal("refused request reached mirror") + } + cursor, err := s.Cursor(context.Background()) + if err != nil || cursor.HeadSeq != 0 { + t.Fatalf("refused request advanced head: %v, %v", cursor, err) + } +} diff --git a/module/services/policy-log/code/witness/contract.go b/module/services/policy-log/code/witness/contract.go new file mode 100644 index 000000000..aeda00d66 --- /dev/null +++ b/module/services/policy-log/code/witness/contract.go @@ -0,0 +1,168 @@ +// Package witness implements an external, signed, append-only policy log. +package witness + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "time" + "unicode" + "unicode/utf8" + + "policy-log/objectstore" +) + +const ( + AppendDeadline = 5 * time.Second + MaxReadLimit = 500 + HeadObject = objectstore.HeadObject +) + +var ( + ErrInvalid = errors.New("invalid policy log request") + ErrConflict = errors.New("operation id already names a different entry") + ErrIntegrity = errors.New("policy log integrity check failed") + ErrRollback = errors.New("policy log head moved backwards or changed history") + ErrPending = errors.New("next sequence holds an uncommitted operation; retry that exact operation") +) + +// Entry carries opaque identifiers, never domain content. NarrowingHash is the +// SHA-256 of the caller's canonical operation bytes. RequestedAt belongs to that +// operation and must remain unchanged on a retry; it is not a request deadline. +type Entry struct { + OperationID string `json:"operation_id"` + Kind string `json:"kind"` + Subject string `json:"subject"` + Tenant string `json:"tenant"` + NarrowingHash []byte `json:"narrowing_hash"` + RequestedAt time.Time `json:"requested_at"` +} + +type Receipt struct { + Seq uint64 `json:"seq"` + EntryHash []byte `json:"entry_hash"` + PrevHash []byte `json:"prev_hash"` + ChainHash []byte `json:"chain_hash"` + IssuedAt time.Time `json:"issued_at"` + Signature []byte `json:"signature"` +} + +type Record struct { + Entry Entry `json:"entry"` + Receipt Receipt `json:"receipt"` +} + +type Cursor struct { + HeadSeq uint64 `json:"head_seq"` + ChainHash []byte `json:"chain_hash"` + IssuedAt time.Time `json:"issued_at"` + Signature []byte `json:"signature"` +} + +// Key is also the schema of the public trust-anchor document. A network key +// response is discovery, never authority to replace a caller's pinned key. +type Key struct { + Algorithm string `json:"algorithm"` + KeyID string `json:"key_id"` + PublicKey []byte `json:"public_key"` +} + +func validTime(t time.Time) bool { return !t.IsZero() && t.Year() >= 1 && t.Year() <= 9999 } + +func (e Entry) Validate() error { + for _, field := range []struct { + value string + required bool + max int + }{ + {e.OperationID, true, 1024}, {e.Kind, true, 128}, {e.Subject, true, 1024}, {e.Tenant, false, 1024}, + } { + if (field.required && field.value == "") || len(field.value) > field.max || !utf8.ValidString(field.value) { + return fmt.Errorf("%w: identifier is missing, oversized or not UTF-8", ErrInvalid) + } + for _, r := range field.value { + if unicode.IsControl(r) { + return fmt.Errorf("%w: control character in identifier", ErrInvalid) + } + } + } + if len(e.NarrowingHash) != sha256.Size || !validTime(e.RequestedAt) { + return fmt.Errorf("%w: SHA-256 digest and requested_at are required", ErrInvalid) + } + return nil +} + +// Canonical encodings use domain separation, big-endian unsigned integers, +// u32-length-prefixed UTF-8 strings, raw 32-byte digests, and a timestamp encoded +// as Unix seconds (int64's two's-complement bits) then u32 nanoseconds. There is +// no JSON serialization or protobuf field-order dependency in a signature. +func put64(b *bytes.Buffer, n uint64) { _ = binary.Write(b, binary.BigEndian, n) } +func put32(b *bytes.Buffer, n uint32) { _ = binary.Write(b, binary.BigEndian, n) } +func putString(b *bytes.Buffer, s string) { put32(b, uint32(len(s))); b.WriteString(s) } +func putTime(b *bytes.Buffer, t time.Time) { + put64(b, uint64(t.Unix())) + put32(b, uint32(t.Nanosecond())) +} + +func EntryHash(e Entry) ([]byte, error) { + if err := e.Validate(); err != nil { + return nil, err + } + b := bytes.NewBufferString("policy-log.entry.v1\x00") + for _, s := range []string{e.OperationID, e.Kind, e.Subject, e.Tenant} { + putString(b, s) + } + b.Write(e.NarrowingHash) + putTime(b, e.RequestedAt) + h := sha256.Sum256(b.Bytes()) + return h[:], nil +} + +func ChainHash(seq uint64, prev, entry []byte) []byte { + b := bytes.NewBufferString("policy-log.chain.v1\x00") + put64(b, seq) + b.Write(prev) + b.Write(entry) + h := sha256.Sum256(b.Bytes()) + return h[:] +} + +func ReceiptBytes(r Receipt) []byte { + b := bytes.NewBufferString("policy-log.receipt.v1\x00") + put64(b, r.Seq) + b.Write(r.EntryHash) + b.Write(r.PrevHash) + b.Write(r.ChainHash) + putTime(b, r.IssuedAt) + return b.Bytes() +} + +func CursorBytes(c Cursor) []byte { + b := bytes.NewBufferString("policy-log.cursor.v1\x00") + put64(b, c.HeadSeq) + b.Write(c.ChainHash) + putTime(b, c.IssuedAt) + return b.Bytes() +} + +func VerifyReceipt(key ed25519.PublicKey, r Receipt) bool { + return len(key) == ed25519.PublicKeySize && r.Seq != 0 && validTime(r.IssuedAt) && + len(r.EntryHash) == sha256.Size && len(r.PrevHash) == sha256.Size && len(r.ChainHash) == sha256.Size && + bytes.Equal(r.ChainHash, ChainHash(r.Seq, r.PrevHash, r.EntryHash)) && ed25519.Verify(key, ReceiptBytes(r), r.Signature) +} + +func VerifyCursor(key ed25519.PublicKey, c Cursor) bool { + return len(key) == ed25519.PublicKeySize && len(c.ChainHash) == sha256.Size && validTime(c.IssuedAt) && + (c.HeadSeq != 0 || bytes.Equal(c.ChainHash, make([]byte, sha256.Size))) && ed25519.Verify(key, CursorBytes(c), c.Signature) +} + +func EntryObject(seq uint64) string { return fmt.Sprintf("entries/%020d.json", seq) } + +func keyDocument(public ed25519.PublicKey) Key { + id := sha256.Sum256(public) + return Key{Algorithm: "Ed25519", KeyID: hex.EncodeToString(id[:]), PublicKey: bytes.Clone(public)} +} diff --git a/module/services/policy-log/code/witness/log.go b/module/services/policy-log/code/witness/log.go new file mode 100644 index 000000000..0d4f9c473 --- /dev/null +++ b/module/services/policy-log/code/witness/log.go @@ -0,0 +1,324 @@ +package witness + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "time" + + "policy-log/objectstore" +) + +type head struct { + Seq uint64 `json:"seq"` + ChainHash []byte `json:"chain_hash"` +} + +type Log struct { + store objectstore.Store + private ed25519.PrivateKey + public ed25519.PublicKey + gate chan struct{} + head head + generation int64 + records map[uint64]Record + operations map[string]uint64 +} + +// Open verifies the committed chain before the service may listen. A different +// signing key cannot reopen an existing log. The in-memory index is disposable: +// the immutable objects and generation-guarded head are the authority. +func Open(ctx context.Context, store objectstore.Store, key ed25519.PrivateKey) (*Log, error) { + if store == nil || len(key) != ed25519.PrivateKeySize || !bytes.Equal(key, ed25519.NewKeyFromSeed(key.Seed())) { + return nil, fmt.Errorf("policy log requires storage and a valid Ed25519 signing key") + } + l := &Log{store: store, private: bytes.Clone(key), public: bytes.Clone(key.Public().(ed25519.PublicKey)), + gate: make(chan struct{}, 1), head: head{ChainHash: make([]byte, sha256.Size)}, + records: make(map[uint64]Record), operations: make(map[string]uint64)} + if err := l.refresh(ctx); err != nil { + return nil, err + } + return l, nil +} + +func (l *Log) Keys() Key { return keyDocument(l.public) } + +func (l *Log) lock(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + select { + case l.gate <- struct{}{}: + if err := ctx.Err(); err != nil { + <-l.gate + return err + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func decode(data []byte, value any) error { + if len(data) > objectstore.MaxObjectBytes { + return ErrIntegrity + } + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(value); err != nil { + return fmt.Errorf("%w: malformed object", ErrIntegrity) + } + if err := d.Decode(new(any)); !errors.Is(err, io.EOF) { + return fmt.Errorf("%w: trailing object bytes", ErrIntegrity) + } + return nil +} + +func (l *Log) checkRecord(r Record, seq uint64, prev []byte) error { + h, err := EntryHash(r.Entry) + if err != nil || r.Receipt.Seq != seq || !bytes.Equal(h, r.Receipt.EntryHash) || + !bytes.Equal(prev, r.Receipt.PrevHash) || !VerifyReceipt(l.public, r.Receipt) { + return fmt.Errorf("%w: sequence %d", ErrIntegrity, seq) + } + return nil +} + +func (l *Log) loadRecord(ctx context.Context, seq uint64) (Record, error) { + obj, err := l.store.Read(ctx, EntryObject(seq)) + if err != nil { + return Record{}, err + } + var r Record + if err := decode(obj.Data, &r); err != nil { + return Record{}, err + } + return r, ctx.Err() +} + +// refresh never publishes a partial read, a lower cursor, or a changed prefix. +// Even an idempotent append re-reads the head: a cached receipt must not hide an +// unreachable or rolled-back authority store. +func (l *Log) refresh(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + obj, err := l.store.Read(ctx, HeadObject) + if errors.Is(err, objectstore.ErrNotFound) { + // Genesis is provisioned once in the independent trust domain. Treating + // a deleted head as a new log could silently reuse old sequence numbers. + return fmt.Errorf("%w: required head object is absent", ErrRollback) + } + if err != nil { + return err + } + var next head + if err := decode(obj.Data, &next); err != nil { + return err + } + if obj.Generation <= 0 || len(next.ChainHash) != sha256.Size { + return ErrIntegrity + } + if next.Seq < l.head.Seq || obj.Generation < l.generation || + (next.Seq == l.head.Seq && !bytes.Equal(next.ChainHash, l.head.ChainHash)) { + return ErrRollback + } + if next.Seq == l.head.Seq { + l.generation = obj.Generation + return ctx.Err() + } + prev := l.head.ChainHash + var pending []Record + seen := make(map[string]bool) + for seq := l.head.Seq + 1; ; seq++ { + r, err := l.loadRecord(ctx, seq) + if err != nil { + return fmt.Errorf("read committed sequence %d: %w", seq, err) + } + if err := l.checkRecord(r, seq, prev); err != nil { + return err + } + if _, exists := l.operations[r.Entry.OperationID]; exists || seen[r.Entry.OperationID] { + return fmt.Errorf("%w: duplicate operation id", ErrIntegrity) + } + seen[r.Entry.OperationID] = true + pending = append(pending, r) + prev = r.Receipt.ChainHash + if seq == next.Seq { + break + } + } + if !bytes.Equal(next.ChainHash, prev) { + return ErrIntegrity + } + if err := ctx.Err(); err != nil { + return err + } + for _, r := range pending { + l.remember(r) + } + l.head, l.generation = next, obj.Generation + return nil +} + +func (l *Log) remember(r Record) { + l.records[r.Receipt.Seq] = r + l.operations[r.Entry.OperationID] = r.Receipt.Seq +} + +func cloneReceipt(r Receipt) Receipt { + r.EntryHash, r.PrevHash, r.ChainHash, r.Signature = bytes.Clone(r.EntryHash), bytes.Clone(r.PrevHash), bytes.Clone(r.ChainHash), bytes.Clone(r.Signature) + return r +} + +func cloneRecord(r Record) Record { + r.Entry.NarrowingHash = bytes.Clone(r.Entry.NarrowingHash) + r.Receipt = cloneReceipt(r.Receipt) + return r +} + +// Append's budget includes queueing and refreshing. There is one attempt at +// each conditional write, with no detached writer and no re-mint on conflict. +func (l *Log) Append(ctx context.Context, entry Entry) (*Receipt, error) { + ctx, cancel := context.WithTimeout(ctx, AppendDeadline) + defer cancel() + if err := l.lock(ctx); err != nil { + return nil, err + } + defer func() { <-l.gate }() + if err := entry.Validate(); err != nil { + return nil, err + } + entry.NarrowingHash = bytes.Clone(entry.NarrowingHash) + hash, err := EntryHash(entry) + if err != nil { + return nil, err + } + if err := l.refresh(ctx); err != nil { + return nil, err + } + if seq, ok := l.operations[entry.OperationID]; ok { + r := l.records[seq].Receipt + if !bytes.Equal(hash, r.EntryHash) { + return nil, ErrConflict + } + if err := ctx.Err(); err != nil { + return nil, err + } + copy := cloneReceipt(r) + return ©, nil + } + if l.head.Seq == math.MaxUint64 { + return nil, fmt.Errorf("%w: sequence exhausted", ErrIntegrity) + } + seq := l.head.Seq + 1 + r, err := l.loadRecord(ctx, seq) + switch { + case err == nil: + // A preceding call may have persisted this slot but lost its head write. + // Only the identical operation may explicitly complete it. Never delete + // the slot, silently skip it, or complete a different operation late. + if err := l.checkRecord(r, seq, l.head.ChainHash); err != nil { + return nil, err + } + if r.Entry.OperationID != entry.OperationID { + return nil, ErrPending + } + if !bytes.Equal(hash, r.Receipt.EntryHash) { + return nil, ErrConflict + } + case errors.Is(err, objectstore.ErrNotFound): + r = Record{Entry: entry, Receipt: Receipt{Seq: seq, EntryHash: hash, PrevHash: bytes.Clone(l.head.ChainHash), + ChainHash: ChainHash(seq, l.head.ChainHash, hash), IssuedAt: time.Now().UTC()}} + r.Receipt.Signature = ed25519.Sign(l.private, ReceiptBytes(r.Receipt)) + body, err := json.Marshal(r) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + if _, err := l.store.Write(ctx, EntryObject(seq), body, 0); err != nil { + return nil, err + } + default: + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + next := head{Seq: seq, ChainHash: bytes.Clone(r.Receipt.ChainHash)} + body, err := json.Marshal(next) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + generation, err := l.store.Write(ctx, HeadObject, body, l.generation) + if err != nil { + return nil, err + } + l.head, l.generation = next, generation + l.remember(r) + if err := ctx.Err(); err != nil { + return nil, err + } + copy := cloneReceipt(r.Receipt) + return ©, nil +} + +func (l *Log) Cursor(ctx context.Context) (*Cursor, error) { + ctx, cancel := context.WithTimeout(ctx, AppendDeadline) + defer cancel() + if err := l.lock(ctx); err != nil { + return nil, err + } + defer func() { <-l.gate }() + if err := l.refresh(ctx); err != nil { + return nil, err + } + c := &Cursor{HeadSeq: l.head.Seq, ChainHash: bytes.Clone(l.head.ChainHash), IssuedAt: time.Now().UTC()} + c.Signature = ed25519.Sign(l.private, CursorBytes(*c)) + if err := ctx.Err(); err != nil { + return nil, err + } + return c, nil +} + +// Read starts at an inclusive sequence (1 is genesis). Asking beyond head+1 +// refuses rather than making a restored store look like a quiet log. Callers +// persist their verified sequence and chain hash across replicas and restarts. +func (l *Log) Read(ctx context.Context, from uint64, limit uint32) ([]Record, error) { + ctx, cancel := context.WithTimeout(ctx, AppendDeadline) + defer cancel() + if from == 0 || limit == 0 || limit > MaxReadLimit { + return nil, ErrInvalid + } + if err := l.lock(ctx); err != nil { + return nil, err + } + defer func() { <-l.gate }() + if err := l.refresh(ctx); err != nil { + return nil, err + } + if from > l.head.Seq && from-l.head.Seq > 1 { + return nil, ErrRollback + } + records := make([]Record, 0, limit) + for seq := from; seq <= l.head.Seq && uint32(len(records)) < limit; seq++ { + records = append(records, cloneRecord(l.records[seq])) + if seq == math.MaxUint64 { + break + } + } + if err := ctx.Err(); err != nil { + return nil, err + } + return records, nil +} diff --git a/module/services/policy-log/code/witness/log_test.go b/module/services/policy-log/code/witness/log_test.go new file mode 100644 index 000000000..1dee4c7b8 --- /dev/null +++ b/module/services/policy-log/code/witness/log_test.go @@ -0,0 +1,508 @@ +package witness + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "policy-log/objectstore" + "policy-log/objectstore/memory" +) + +func newStore(t *testing.T) *memory.Store { + t.Helper() + s := memory.New() + body, err := json.Marshal(head{ChainHash: make([]byte, sha256.Size)}) + if err != nil { + t.Fatal(err) + } + if _, err := s.Write(context.Background(), HeadObject, body, 0); err != nil { + t.Fatal(err) + } + return s +} + +func TestMissingHeadRefusesBoot(t *testing.T) { + if _, err := Open(context.Background(), memory.New(), signingKey(t)); !errors.Is(err, ErrRollback) { + t.Fatalf("missing head accepted: %v", err) + } +} + +func signingKey(t *testing.T) ed25519.PrivateKey { + t.Helper() + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return key +} + +func openLog(t *testing.T, store objectstore.Store, key ed25519.PrivateKey) *Log { + t.Helper() + l, err := Open(context.Background(), store, key) + if err != nil { + t.Fatal(err) + } + return l +} + +func entry(id string) Entry { + h := sha256.Sum256([]byte("canonical narrowing bytes")) + return Entry{OperationID: id, Kind: "narrowed", Subject: "example-principal", Tenant: "example-tenant", + NarrowingHash: h[:], RequestedAt: time.Date(2026, 10, 1, 10, 20, 30, 456, time.UTC)} +} + +func appendEntry(t *testing.T, l *Log, e Entry) *Receipt { + t.Helper() + r, err := l.Append(context.Background(), e) + if err != nil || r == nil { + t.Fatalf("Append = %v, %v", r, err) + } + return r +} + +// Independently encode the contract, rather than verifying the implementation +// with the encoder that signed it. This is also a minimal client example. +func independentEntryHash(e Entry) []byte { + b := []byte("policy-log.entry.v1\x00") + for _, s := range []string{e.OperationID, e.Kind, e.Subject, e.Tenant} { + b = binary.BigEndian.AppendUint32(b, uint32(len(s))) + b = append(b, s...) + } + b = append(b, e.NarrowingHash...) + b = binary.BigEndian.AppendUint64(b, uint64(e.RequestedAt.Unix())) + b = binary.BigEndian.AppendUint32(b, uint32(e.RequestedAt.Nanosecond())) + h := sha256.Sum256(b) + return h[:] +} + +func independentReceiptBytes(r Receipt) []byte { + b := binary.BigEndian.AppendUint64([]byte("policy-log.receipt.v1\x00"), r.Seq) + for _, h := range [][]byte{r.EntryHash, r.PrevHash, r.ChainHash} { + b = append(b, h...) + } + b = binary.BigEndian.AppendUint64(b, uint64(r.IssuedAt.Unix())) + return binary.BigEndian.AppendUint32(b, uint32(r.IssuedAt.Nanosecond())) +} + +func TestReadVerifiesEntireChainAndPagination(t *testing.T) { + l := openLog(t, newStore(t), signingKey(t)) + for i := 1; i <= 4; i++ { + appendEntry(t, l, entry(fmt.Sprint(i))) + } + var all []Record + for _, from := range []uint64{1, 3} { + page, err := l.Read(context.Background(), from, 2) + if err != nil || len(page) != 2 { + t.Fatalf("Read(%d) = %v, %v", from, page, err) + } + all = append(all, page...) + } + prev := make([]byte, sha256.Size) + for i, r := range all { + seq := uint64(i + 1) + hash := independentEntryHash(r.Entry) + b := binary.BigEndian.AppendUint64([]byte("policy-log.chain.v1\x00"), seq) + b = append(b, prev...) + b = append(b, hash...) + chain := sha256.Sum256(b) + if r.Receipt.Seq != seq || !bytes.Equal(hash, r.Receipt.EntryHash) || + !bytes.Equal(prev, r.Receipt.PrevHash) || !bytes.Equal(chain[:], r.Receipt.ChainHash) { + t.Fatalf("chain broken at %d", seq) + } + prev = chain[:] + } + c, err := l.Cursor(context.Background()) + if err != nil || c.HeadSeq != 4 || !bytes.Equal(c.ChainHash, prev) { + t.Fatalf("cursor = %v, %v", c, err) + } + page, err := l.Read(context.Background(), 5, 1) + if err != nil || len(page) != 0 { + t.Fatalf("quiet log = %v, %v", page, err) + } +} + +func TestReceiptVerifiesUnderKeysAndRejectsAnotherKey(t *testing.T) { + l := openLog(t, newStore(t), signingKey(t)) + r := appendEntry(t, l, entry("one")) + keys := l.Keys() + if keys.Algorithm != "Ed25519" || len(keys.KeyID) != 64 || !ed25519.Verify(keys.PublicKey, independentReceiptBytes(*r), r.Signature) { + t.Fatal("receipt does not verify under published key") + } + other := signingKey(t).Public().(ed25519.PublicKey) + if ed25519.Verify(other, independentReceiptBytes(*r), r.Signature) { + t.Fatal("another key accepted receipt") + } + for _, mutate := range []func(*Receipt){ + func(r *Receipt) { r.Seq++ }, func(r *Receipt) { r.EntryHash[0] ^= 1 }, + func(r *Receipt) { r.PrevHash[0] ^= 1 }, func(r *Receipt) { r.ChainHash[0] ^= 1 }, + func(r *Receipt) { r.IssuedAt = r.IssuedAt.Add(time.Nanosecond) }, func(r *Receipt) { r.Signature[0] ^= 1 }, + } { + altered := cloneReceipt(*r) + mutate(&altered) + if VerifyReceipt(keys.PublicKey, altered) { + t.Fatal("tampered receipt verified") + } + } +} + +func TestIdempotentReappendReturnsSameReceiptAfterRestart(t *testing.T) { + s, key := newStore(t), signingKey(t) + l := openLog(t, s, key) + first := appendEntry(t, l, entry("same")) + appendEntry(t, l, entry("later")) + l = openLog(t, s, key) + second := appendEntry(t, l, entry("same")) + if !reflect.DeepEqual(first, second) { + t.Fatalf("receipt reminted: %v != %v", first, second) + } + c, err := l.Cursor(context.Background()) + if err != nil || c.HeadSeq != 2 { + t.Fatalf("idempotent call advanced head: %v, %v", c, err) + } +} + +func TestOperationIDReuseWithDifferentEntryIsRefused(t *testing.T) { + l := openLog(t, newStore(t), signingKey(t)) + appendEntry(t, l, entry("same")) + for _, mutate := range []func(*Entry){ + func(e *Entry) { e.Kind = "regranted" }, func(e *Entry) { e.Subject = "other" }, + func(e *Entry) { e.Tenant = "other" }, func(e *Entry) { e.NarrowingHash[0] ^= 1 }, + func(e *Entry) { e.RequestedAt = e.RequestedAt.Add(time.Nanosecond) }, + } { + e := entry("same") + mutate(&e) + r, err := l.Append(context.Background(), e) + if r != nil || !errors.Is(err, ErrConflict) { + t.Fatalf("reused operation accepted: %v, %v", r, err) + } + } +} + +type interceptStore struct { + objectstore.Store + write func(context.Context, string, []byte, int64) (int64, error) + read func(context.Context, string) (objectstore.Object, error) +} + +func (s *interceptStore) Write(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if s.write != nil { + return s.write(ctx, name, b, g) + } + return s.Store.Write(ctx, name, b, g) +} +func (s *interceptStore) Read(ctx context.Context, name string) (objectstore.Object, error) { + if s.read != nil { + return s.read(ctx, name) + } + return s.Store.Read(ctx, name) +} + +func TestConcurrentSecondAppenderLosesHeadPreconditionAndIssuesNoReceipt(t *testing.T) { + s, key := newStore(t), signingKey(t) + // A crash after the immutable entry leaves a candidate, not a receipt. + interrupted := &interceptStore{Store: s} + interrupted.write = func(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if name == HeadObject { + return 0, objectstore.ErrPrecondition + } + return s.Write(ctx, name, b, g) + } + r, err := openLog(t, interrupted, key).Append(context.Background(), entry("same")) + if r != nil || !errors.Is(err, objectstore.ErrPrecondition) { + t.Fatalf("failed head write returned receipt: %v, %v", r, err) + } + var arrivals atomic.Int32 + ready := make(chan struct{}) + racing := &interceptStore{Store: s} + racing.write = func(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if name == HeadObject { + if arrivals.Add(1) == 2 { + close(ready) + } + select { + case <-ready: + case <-ctx.Done(): + return 0, ctx.Err() + } + } + return s.Write(ctx, name, b, g) + } + a, b := openLog(t, racing, key), openLog(t, racing, key) + type answer struct { + receipt *Receipt + err error + } + answers := make(chan answer, 2) + for _, l := range []*Log{a, b} { + go func() { r, e := l.Append(context.Background(), entry("same")); answers <- answer{r, e} }() + } + successes, losses := 0, 0 + for range 2 { + result := <-answers + switch { + case result.err == nil && result.receipt != nil: + successes++ + case errors.Is(result.err, objectstore.ErrPrecondition) && result.receipt == nil: + losses++ + default: + t.Fatalf("unexpected append: %+v", result) + } + } + if successes != 1 || losses != 1 || arrivals.Load() != 2 { + t.Fatalf("successes=%d, losses=%d, head attempts=%d", successes, losses, arrivals.Load()) + } +} + +func TestConcurrentDifferentOperationsCannotOverwriteSequence(t *testing.T) { + s, key := newStore(t), signingKey(t) + var arrivals atomic.Int32 + ready := make(chan struct{}) + racing := &interceptStore{Store: s} + racing.write = func(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if name == EntryObject(1) { + if arrivals.Add(1) == 2 { + close(ready) + } + select { + case <-ready: + case <-ctx.Done(): + return 0, ctx.Err() + } + } + return s.Write(ctx, name, b, g) + } + a, b := openLog(t, racing, key), openLog(t, racing, key) + var wg sync.WaitGroup + var successes, losses atomic.Int32 + for i, l := range []*Log{a, b} { + wg.Go(func() { + r, err := l.Append(context.Background(), entry(fmt.Sprint(i))) + if err == nil && r != nil { + successes.Add(1) + } else if errors.Is(err, objectstore.ErrPrecondition) && r == nil { + losses.Add(1) + } else { + t.Errorf("unexpected result: %v, %v", r, err) + } + }) + } + wg.Wait() + if successes.Load() != 1 || losses.Load() != 1 { + t.Fatalf("successes=%d, losses=%d", successes.Load(), losses.Load()) + } +} + +func TestUncommittedEntryIsInvisibleAndOnlyExactRetryCanComplete(t *testing.T) { + s, key := newStore(t), signingKey(t) + broken := &interceptStore{Store: s, write: func(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if name == HeadObject { + return 0, errors.New("head unavailable") + } + return s.Write(ctx, name, b, g) + }} + r, err := openLog(t, broken, key).Append(context.Background(), entry("original")) + if err == nil || r != nil { + t.Fatal("issued receipt before head commit") + } + l := openLog(t, s, key) + rows, err := l.Read(context.Background(), 1, 10) + if err != nil || len(rows) != 0 { + t.Fatalf("uncommitted row visible: %v, %v", rows, err) + } + r, err = l.Append(context.Background(), entry("other")) + if !errors.Is(err, ErrPending) || r != nil { + t.Fatalf("overwrote pending slot: %v, %v", r, err) + } + bad := entry("original") + bad.Kind = "regranted" + r, err = l.Append(context.Background(), bad) + if !errors.Is(err, ErrConflict) || r != nil { + t.Fatalf("changed pending operation: %v, %v", r, err) + } + appendEntry(t, l, entry("original")) +} + +func TestCursorIsSignedAndForwardOnlyAcrossAppenders(t *testing.T) { + s, key := newStore(t), signingKey(t) + a, b := openLog(t, s, key), openLog(t, s, key) + c, err := b.Cursor(context.Background()) + if err != nil || c.HeadSeq != 0 || !VerifyCursor(b.Keys().PublicKey, *c) { + t.Fatalf("genesis cursor: %v, %v", c, err) + } + appendEntry(t, a, entry("one")) + old, _ := s.Read(context.Background(), HeadObject) + c, err = b.Cursor(context.Background()) + if err != nil || c.HeadSeq != 1 || !VerifyCursor(b.Keys().PublicKey, *c) { + t.Fatalf("cursor: %v, %v", c, err) + } + appendEntry(t, b, entry("two")) + c, err = a.Cursor(context.Background()) + if err != nil || c.HeadSeq != 2 { + t.Fatalf("cursor: %v, %v", c, err) + } + current, _ := s.Read(context.Background(), HeadObject) + if _, err := s.Write(context.Background(), HeadObject, old.Data, current.Generation); err != nil { + t.Fatal(err) + } + c, err = a.Cursor(context.Background()) + if c != nil || !errors.Is(err, ErrRollback) { + t.Fatalf("rollback served: %v, %v", c, err) + } + rows, err := a.Read(context.Background(), 1, 1) + if rows != nil || !errors.Is(err, ErrRollback) { + t.Fatalf("rollback read served: %v, %v", rows, err) + } +} + +func TestUnreadableHeadRefusesReadsAndIdempotentAppend(t *testing.T) { + s := &interceptStore{Store: newStore(t)} + l := openLog(t, s, signingKey(t)) + appendEntry(t, l, entry("one")) + s.read = func(context.Context, string) (objectstore.Object, error) { + return objectstore.Object{}, errors.New("unreachable") + } + if r, err := l.Append(context.Background(), entry("one")); err == nil || r != nil { + t.Fatal("cached receipt hid unreachable log") + } + if c, err := l.Cursor(context.Background()); err == nil || c != nil { + t.Fatal("cached cursor hid unreachable log") + } + if rows, err := l.Read(context.Background(), 1, 1); err == nil || rows != nil { + t.Fatal("cached entries hid unreachable log") + } +} + +func TestAppendPastDeadlineIsRefusedWithoutWriting(t *testing.T) { + s := &interceptStore{Store: newStore(t)} + l := openLog(t, s, signingKey(t)) + s.write = func(context.Context, string, []byte, int64) (int64, error) { + t.Error("write after deadline") + return 0, nil + } + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + r, err := l.Append(ctx, entry("one")) + if r != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("late Append: %v, %v", r, err) + } +} + +func TestDeadlineDuringHeadWriteDoesNotCommitLate(t *testing.T) { + s := newStore(t) + delayed := &interceptStore{Store: s, write: func(ctx context.Context, name string, b []byte, g int64) (int64, error) { + if name == HeadObject { + <-ctx.Done() + } + return s.Write(ctx, name, b, g) + }} + l := openLog(t, delayed, signingKey(t)) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + r, err := l.Append(ctx, entry("one")) + if r != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("late head write: %v, %v", r, err) + } + obj, err := s.Read(context.Background(), HeadObject) + if err != nil { + t.Fatal(err) + } + var h head + if err := json.Unmarshal(obj.Data, &h); err != nil || h.Seq != 0 { + t.Fatalf("late head committed: %v, %v", h, err) + } +} + +func TestAppendWithoutCallerDeadlineIsBoundedAtFiveSeconds(t *testing.T) { + s := &interceptStore{Store: newStore(t)} + l := openLog(t, s, signingKey(t)) + s.read = func(ctx context.Context, _ string) (objectstore.Object, error) { + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > 5*time.Second { + t.Fatal("append has no five-second budget") + } + <-ctx.Done() + return objectstore.Object{}, ctx.Err() + } + start := time.Now() + r, err := l.Append(context.Background(), entry("one")) + if r != nil || !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 5500*time.Millisecond { + t.Fatalf("unbounded append: %v, %v (%v)", r, err, time.Since(start)) + } +} + +func TestWaitingAppenderHonorsItsDeadline(t *testing.T) { + l := openLog(t, newStore(t), signingKey(t)) + l.gate <- struct{}{} + defer func() { <-l.gate }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + r, err := l.Append(ctx, entry("one")) + if r != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("queued append: %v, %v", r, err) + } +} + +func TestOpenRejectsTamperedHistoryAndAnotherSigningKey(t *testing.T) { + s, key := newStore(t), signingKey(t) + l := openLog(t, s, key) + appendEntry(t, l, entry("one")) + if _, err := Open(context.Background(), s, signingKey(t)); !errors.Is(err, ErrIntegrity) { + t.Fatalf("wrong key: %v", err) + } + obj, _ := s.Read(context.Background(), EntryObject(1)) + var record Record + if err := json.Unmarshal(obj.Data, &record); err != nil { + t.Fatal(err) + } + record.Entry.Subject = "tampered" + body, _ := json.Marshal(record) + if _, err := s.Write(context.Background(), EntryObject(1), body, obj.Generation); err != nil { + t.Fatal(err) + } + if _, err := Open(context.Background(), s, key); !errors.Is(err, ErrIntegrity) { + t.Fatalf("tampered history: %v", err) + } +} + +func TestInvalidRequestsAndMutableResultsCannotChangeLog(t *testing.T) { + l := openLog(t, newStore(t), signingKey(t)) + bad := entry("bad") + bad.NarrowingHash = []byte{1} + if r, err := l.Append(context.Background(), bad); r != nil || !errors.Is(err, ErrInvalid) { + t.Fatalf("bad digest: %v, %v", r, err) + } + for _, q := range []struct { + from uint64 + limit uint32 + }{{0, 1}, {1, 0}, {1, 501}, {2, 1}} { + if rows, err := l.Read(context.Background(), q.from, q.limit); err == nil || rows != nil { + t.Fatalf("bad read accepted: %+v", q) + } + } + first := appendEntry(t, l, entry("one")) + first.Signature[0] ^= 1 + rows, err := l.Read(context.Background(), 1, 1) + if err != nil { + t.Fatal(err) + } + rows[0].Entry.NarrowingHash[0] ^= 1 + rows[0].Receipt.ChainHash[0] ^= 1 + k := l.Keys() + k.PublicKey[0] ^= 1 + r := appendEntry(t, l, entry("one")) + if !VerifyReceipt(l.Keys().PublicKey, *r) { + t.Fatal("caller mutated committed receipt or public key") + } +} diff --git a/module/services/policy-log/proto/README.md b/module/services/policy-log/proto/README.md new file mode 100644 index 000000000..1982a57f4 --- /dev/null +++ b/module/services/policy-log/proto/README.md @@ -0,0 +1,25 @@ +# Policy log protobuf input + +`saas/policylog/v1/witness.proto` is the only transport projection of the +authored Go contract in [../code/witness/contract.go](../code/witness/contract.go). +It carries the four operations the transport-independent service exposes, and +nothing else: there is no REST annotation, no Connect binding and no OpenAPI +document, because the endpoint is private to this module and reaches no gateway. + +It deliberately takes **no buf dependency**. `google/protobuf/timestamp.proto` +is a well-known type the toolchain already carries, so generation needs no +registry fetch. Validation is not duplicated here either — `Entry.Validate` in +the Go contract is the one authority for identifier limits and the 32-byte +digest, so a protovalidate annotation would be a second place to disagree. + +Regenerate from `module/services/policy-log` with Docker running: + +```sh +codefly generate proto --proto ./proto --output .. --template policy-log/proto/buf.gen.yaml +``` + +The companion image pins every plugin and runs `goimports` over the Go it +wrote. Never run `buf generate` directly: it skips that pass, and +`module/tools/generated-pins-gate.mjs` fails on the resulting import shape. +`buf.gen.local.yaml` beside the service manifest exists only so that gate can +read the plugin versions the image runs; it is not an alternative command. diff --git a/module/services/policy-log/proto/buf.gen.yaml b/module/services/policy-log/proto/buf.gen.yaml new file mode 100644 index 000000000..bebeb5eeb --- /dev/null +++ b/module/services/policy-log/proto/buf.gen.yaml @@ -0,0 +1,16 @@ +version: v2 +clean: true +managed: + enabled: true + override: + - file_option: go_package_prefix + value: policy-log/pkg/gen +plugins: + # Codefly's versioned proto companion owns the byte-generating toolchain. + # Local plugins avoid mutable registry execution and anonymous rate limits. + - local: protoc-gen-go + out: ../code/pkg/gen + opt: paths=source_relative + - local: protoc-gen-go-grpc + out: ../code/pkg/gen + opt: paths=source_relative diff --git a/module/services/policy-log/proto/buf.yaml b/module/services/policy-log/proto/buf.yaml new file mode 100644 index 000000000..734b79ba2 --- /dev/null +++ b/module/services/policy-log/proto/buf.yaml @@ -0,0 +1,7 @@ +version: v2 +modules: + - path: . +lint: + use: + - STANDARD + disallow_comment_ignores: true diff --git a/module/services/policy-log/proto/saas/policylog/v1/witness.proto b/module/services/policy-log/proto/saas/policylog/v1/witness.proto new file mode 100644 index 000000000..2ce7ac221 --- /dev/null +++ b/module/services/policy-log/proto/saas/policylog/v1/witness.proto @@ -0,0 +1,172 @@ +syntax = "proto3"; + +package saas.policylog.v1; + +import "google/protobuf/timestamp.proto"; + +// Entry is one operation offered to the witness. +// +// It carries OPAQUE IDENTIFIERS ONLY. No domain content ever reaches this log: +// narrowing_hash is the SHA-256 of the caller's canonical operation bytes, and +// the witness stores that digest rather than the operation. Reconciliation can +// identify an operation and verify its bytes; it cannot reconstruct them, so +// the caller must retain or recover the canonical bytes to apply it. +// +// Every field takes part in entry_hash, so every field must stay IDENTICAL on a +// retry of the same operation_id — requested_at included. The witness refuses a +// reused operation_id that names different bytes. +message Entry { + // operation_id identifies one logical operation for the lifetime of the log, + // and it is the caller's: only the caller knows that two attempts are the + // same operation. At most 1024 bytes of control-character-free UTF-8. + string operation_id = 1; + + // kind is what the operation is about, at most 128 bytes. + string kind = 2; + + // subject is what the operation acts on, at most 1024 bytes. + string subject = 3; + + // tenant may be empty for a global operation. It grants no authority: this + // log performs no tenant scoping and the field is an identifier, not a claim. + string tenant = 4; + + // narrowing_hash is exactly 32 bytes: the SHA-256 of the caller's canonical + // operation bytes, carried raw rather than hex or base64 so the digest the + // signature covers is the digest on the wire. + bytes narrowing_hash = 5; + + // requested_at is the CALLER'S logical timestamp for the operation, not a + // deadline and not an ordering authority. It is covered by entry_hash, so an + // old operation can be retried without changing its identity only as long as + // this value is sent back unchanged. + google.protobuf.Timestamp requested_at = 6; +} + +// Receipt is the witness's signed acknowledgement of one committed entry. +// +// Only a committed head makes a receipt exist. A signature over a prepared +// candidate that never reached the head is not a receipt and is never returned. +message Receipt { + // seq is the global sequence the witness assigned, never zero and never + // supplied by a caller. The first sequence is 1. Sequence, not clock time, + // orders operations. + uint64 seq = 1; + + // entry_hash is the 32-byte digest of the appended entry. A caller compares + // it against the bytes it asked to append. + bytes entry_hash = 2; + + // prev_hash is the 32-byte chain hash of seq-1, or 32 zero bytes at seq 1. + bytes prev_hash = 3; + + // chain_hash is the 32-byte chain hash at seq. + bytes chain_hash = 4; + + // issued_at comes from the witness, never from a caller. + google.protobuf.Timestamp issued_at = 5; + + // signature is 64 bytes of Ed25519 over the canonical receipt encoding, which + // depends on no protobuf field ordering. + bytes signature = 6; +} + +// Record is one entry together with the original receipt the witness issued for +// it, which is what makes a read independently verifiable. +message Record { + Entry entry = 1; + Receipt receipt = 2; +} + +// Cursor is a signed OBSERVATION of the committed head. It is neither a receipt +// nor a reset instruction: a verifying client never lowers a persisted cursor, +// and a valid signature cannot by itself distinguish a historical cursor from a +// current one, so the client also enforces its own freshness policy. +message Cursor { + uint64 head_seq = 1; + bytes chain_hash = 2; + google.protobuf.Timestamp issued_at = 3; + bytes signature = 4; +} + +// Key is the public half of the witness signing key, and the same schema as the +// trust-anchor document composition delivers to the host. Publishing it is +// discovery, never authority to replace a caller's pinned key. +message Key { + // algorithm is "Ed25519". + string algorithm = 1; + + // key_id is the lowercase SHA-256 hex of the raw public key. + string key_id = 2; + + // public_key is the raw 32-byte public key. + bytes public_key = 3; +} + +// AppendRequest offers one entry. +message AppendRequest { + Entry entry = 1; +} + +// AppendResponse carries the committed receipt. A retry of the identical entry +// returns the exact original receipt rather than appending again. +message AppendResponse { + Receipt receipt = 1; +} + +// CursorRequest takes no argument: there is one log and one head. +message CursorRequest {} + +// CursorResponse carries the signed head observation. +message CursorResponse { + Cursor cursor = 1; +} + +// ReadRequest asks for a bounded page of committed records. +message ReadRequest { + // from_seq is INCLUSIVE and at least 1. Reading at head_seq + 1 returns an + // empty page; asking beyond it is refused, so a reconciler that is ahead of + // the log cannot mistake a rollback for inactivity. A caller holding an + // exclusive cursor sends cursor + 1. + uint64 from_seq = 1; + + // limit is 1 through 500. + uint32 limit = 2; +} + +// ReadResponse carries the page in ascending sequence order. +message ReadResponse { + repeated Record records = 1; +} + +// KeysRequest takes no argument. +message KeysRequest {} + +// KeysResponse carries the public signing key document. +message KeysResponse { + Key key = 1; +} + +// WitnessService is the independent append-only witness for authority changes. +// +// Its protocol is append -> signed receipt -> the CALLER commits with the +// receipt in the same transaction. This listener is PRIVATE to the module: it is +// never published through the public gateway and never offered to a composed +// module. Composition admits only the host's declared client, through +// workload-authenticated mesh policy. +service WitnessService { + // Append commits one entry and returns its receipt, within a five-second + // budget that includes queueing. A refusal carries no receipt and never + // mints a new sequence for a retried operation. + rpc Append(AppendRequest) returns (AppendResponse); + + // Cursor returns the signed committed head. + rpc Cursor(CursorRequest) returns (CursorResponse); + + // Read returns committed records with their original receipts. + rpc Read(ReadRequest) returns (ReadResponse); + + // Keys returns the public signing key. It never returns private material and + // does not authorize trust-anchor replacement. + rpc Keys(KeysRequest) returns (KeysResponse); +} diff --git a/module/services/policy-log/service.codefly.yaml b/module/services/policy-log/service.codefly.yaml new file mode 100644 index 000000000..a500caac5 --- /dev/null +++ b/module/services/policy-log/service.codefly.yaml @@ -0,0 +1,28 @@ +name: policy-log +version: 0.0.0 +description: Independent signed witness for authority changes +agent: + kind: codefly:service + name: go-grpc + version: 0.1.50 + publisher: codefly.dev +workspace-configuration-dependencies: + - policy-log +endpoints: + # Private to this module. The host declares the dependency when its client + # is wired; neither the public gateway nor composed modules get this API. + - name: grpc +spec: + hot-reload: true + # Only the Go and gRPC bindings. There is no OpenAPI document because the + # endpoint is private gRPC and reaches no gateway, and a declared generated + # directory nothing writes is a claim no regeneration can reproduce. + protocol-output-dirs: + - code/pkg/gen + service-account: + name: policy-log + deployment: + # Codefly allocates the endpoint port. This declaration carries no + # accounts/store dependency and no fixed port or injected credential. + public-egress-ports: + - 443 diff --git a/module/services/store/code/go.mod b/module/services/store/code/go.mod index 60d84e43e..4abf3095e 100644 --- a/module/services/store/code/go.mod +++ b/module/services/store/code/go.mod @@ -16,7 +16,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 // indirect + github.com/codefly-dev/core v0.9.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/module/services/store/code/go.sum b/module/services/store/code/go.sum index 99e8ef195..6880066fb 100644 --- a/module/services/store/code/go.sum +++ b/module/services/store/code/go.sum @@ -30,8 +30,12 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.7.2-0.20261003173032-735244e805bf h1:fvJJFtvYVDgXMI1BU1s//hqTAWCCouyzuO7A3PD4drA= +github.com/codefly-dev/core v0.7.2-0.20261003173032-735244e805bf/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003222502-9d5f3fb7d5c3 h1:PSDuh+/pGQPLSPJ7DNVnE5dVypL7TkLvsYu1xbM4W6U= +github.com/codefly-dev/core v0.7.2-0.20261003222502-9d5f3fb7d5c3/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= diff --git a/module/services/store/code/migration_23_consent_replay_test.go b/module/services/store/code/migration_23_consent_replay_test.go new file mode 100644 index 000000000..d1f36b82e --- /dev/null +++ b/module/services/store/code/migration_23_consent_replay_test.go @@ -0,0 +1,301 @@ +package main + +import ( + "database/sql" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" +) + +// Migration 22's backfill, replayed from zero against staged pre-cutover rows. +// +// WHY THIS CANNOT BE TESTED THE ORDINARY WAY. The store's runner keys on +// migration VERSION with no content checksum (`main.go` reads the applied +// version and only errors when that file is ABSENT). So editing an +// already-applied migration in place is silently skipped: the database keeps the +// old SQL, nothing goes dirty, nothing errors, and the suite passes while testing +// the previous schema. A green run after such an edit is not evidence about the +// edit. +// +// This test therefore drops nothing and reuses nothing: it starts a throwaway +// PostgreSQL 16, applies migrations 1..22, stages the rows that only exist +// BEFORE the cutover, then applies 23 alone and reads the disposition back. +// +// WHAT IT PROVES. The backfill matched `solution_targets.solution_id = +// installations.solution_identifier` on any LIVE target. A route alias is a +// reusable property of a target, never its identity — so if a binding was +// withdrawn and a different one later took the same alias, the live target +// belonged to the SECOND binding and an installation that consented to the FIRST +// silently became an installation of the second. Same alias, different solution, +// consent moved with nobody acting, directly beneath a comment saying this +// migration exists to prevent exactly that. +// +// `i.created_at >= t.opened_at` is the fix: the installation must have been +// created during the period this target has been open. +// +// WHAT IT DOES NOT PROVE, said here because the migration's own comment is +// easily read as stronger than it is: the predicate NARROWS the carry-over, it +// does not establish historical consent. `created_at` records when the +// installation row was written, not which binding the administrator was looking +// at; an alias that was reused while a single target period stayed open, or a +// clock that moved, are both outside what a timestamp can answer. What is proved +// below is the one direction that matters for a disposition nobody will review +// case by case: an installation that predates the live period is never carried +// into it. Erring the other way (a carry that should have been a revoke) is not +// detectable from this schema at all, and the fail-closed second statement is +// the only answer to it. +func TestMigration23DoesNotTransferConsentAcrossAnAliasReuse(t *testing.T) { + db, url := throwawayPostgres(t) + + // Two ledgers: 1..22, then 1..23. The second CONTAINS the first, because the + // runner reads the database's current version and requires that migration's + // file to be present in the source — a directory holding only 23 is refused + // with "database is at migration 22, which this ledger does not contain", + // which the third run of this test discovered. Containing it is also what a + // real upgrade looks like: the runner skips what is applied and runs 23. + // + // Nothing past 23 is copied. 24 and 25 are irrelevant here and applying them + // would widen what a failure could mean. + // + // These numbers moved TWICE: the prose still said 1..19/1..20 while the code + // read 21/22, drift from the renumber before this one. They are now the same + // numbers the code uses, because a comment naming a different version than + // the call beneath it is worse than none. + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 22), url); err != nil { + t.Fatalf("apply migrations 1..22: %v", err) + } + + // Stage the alias reuse. One alias, two periods: the first target closed, + // the second live. `kept` was created inside the live target's period and + // must carry over; `moved` predates it — it consented to the closed period, + // and carrying it would be the transfer. + const alias = "shared-alias" + var closedTarget, liveTarget string + mustQuery(t, db, ` + INSERT INTO public.solution_targets (binding_id, solution_id, opened_generation, opened_at, closed_generation, closed_at) + VALUES ('acme.test.first', $1, 1, now() - interval '10 days', 2, now() - interval '5 days') + RETURNING id::text`, &closedTarget, alias) + mustQuery(t, db, ` + INSERT INTO public.solution_targets (binding_id, solution_id, opened_generation, opened_at) + VALUES ('acme.test.second', $1, 1, now() - interval '2 days') + RETURNING id::text`, &liveTarget, alias) + + // TWO organisations, because `idx_installations_active_solution` is UNIQUE on + // (org_id, solution_identifier) for active rows — one organisation cannot + // hold two active installations of the same alias, which the second run of + // this test discovered. Two organisations is also the realistic shape: both + // installed the same alias, one before the alias was reused and one after. + // + // The FK chain each needs: users → organizations, plus two principals and a + // scope node per organisation. Staged explicitly rather than with + // gen_random_uuid(), which the FIRST run discovered — `installations_org_id_fkey`. + earlyOrg, earlyAgent, earlyOwner, earlyNode := stageInstallationParents(t, db, "early@example.com", "early-co") + lateOrg, lateAgent, lateOwner, lateNode := stageInstallationParents(t, db, "late@example.com", "late-co") + + moved := stageInstallation(t, db, alias, "now() - interval '7 days'", + earlyOrg, earlyAgent, earlyOwner, earlyNode) + kept := stageInstallation(t, db, alias, "now() - interval '1 day'", + lateOrg, lateAgent, lateOwner, lateNode) + + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 23), url); err != nil { + t.Fatalf("apply migration 23: %v", err) + } + + // `kept` carries over to the live target it was created under. + if status := statusOf(t, db, kept); status != "active" { + t.Fatalf("an installation created inside the live target's period must stay active, got %q", status) + } + if keptTarget := targetOf(t, db, kept); keptTarget != liveTarget { + t.Fatalf("expected the live target %s, got %q", liveTarget, keptTarget) + } + + // `moved` is REVOKED, not carried. This is the assertion the old SQL fails: + // it would have set target_id to the live target of a different binding. + movedTarget := targetOf(t, db, moved) + if status := statusOf(t, db, moved); status != "revoked" { + t.Fatalf("an installation predating the live target consented to a CLOSED period; it must be revoked, got %q (target %q)", + status, movedTarget) + } + if movedTarget == liveTarget { + t.Fatal("consent was transferred across an alias reuse: the installation now names a target it never consented to") + } + // The closed period's target is not a consolation prize either: the + // disposition is a revocation with a recorded reason, not a carry onto + // whichever row happens to match the alias. + if movedTarget != "" { + t.Fatalf("a revoked installation names no target, got %q (the live period is %s, the closed one %s)", + movedTarget, liveTarget, closedTarget) + } + var reason string + mustQuery(t, db, ` + SELECT COALESCE(revoked_reason, '') FROM public.installations WHERE id = $1::uuid`, &reason, moved) + if !strings.Contains(reason, "no live solution target") { + t.Fatalf("a revocation this migration performed states why, got %q", reason) + } +} + +// throwawayPostgres starts a PostgreSQL 16 for one test and returns a connection +// and its URL. Each test gets its own cluster: these tests apply PARTIAL ledgers +// and stage rows that only exist before a cutover, so a shared cluster would let +// a failure in either be attributed to the other. +// +// Skipped unless the migration replay gate asked for it. `migration-reference-gate.mjs +// --replay` is the one CI step that declares `postgres:16` as an external input +// and has a container runtime; a bare `go test ./...` in this package must not +// start depending on docker, so the gate's signal is the switch. The gate selects +// `-run '^TestMigration'`, which is why every test here carries that prefix. +func throwawayPostgres(t *testing.T) (*sql.DB, string) { + t.Helper() + if os.Getenv("MIGRATION_CONSENT_REPLAY") == "" { + t.Skip("needs a container runtime; run through migration-reference-gate.mjs --replay") + } + docker := func(args ...string) string { + t.Helper() + out, err := exec.Command("docker", args...).CombinedOutput() + if err != nil { + t.Fatalf("docker %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + // Pull up front: `docker run -d` interleaves pull progress with the id, so + // the id is only reliably the last line once the image is local. + docker("pull", "postgres:16") + started := docker("run", "--rm", "-d", "-e", "POSTGRES_PASSWORD=example", + "-p", "127.0.0.1::5432", "postgres:16") + id := started[strings.LastIndex(started, "\n")+1:] + t.Cleanup(func() { docker("rm", "-f", id) }) + + port := strings.TrimPrefix(docker("port", id, "5432/tcp"), "127.0.0.1:") + url := "postgres://postgres:example@127.0.0.1:" + port + "/postgres?sslmode=disable" + db, err := sql.Open("postgres", url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + // Generously long, and deliberately not tuned to how fast this starts on an + // idle machine. A readiness timeout here says nothing about the migration + // under test, so a short one converts "the host was busy" into a red that + // reads as a schema failure — which is what the fourth run of this test did, + // with four agents starting containers at once. The last ping error is + // reported so a genuine refusal is still distinguishable from slowness. + deadline := time.Now().Add(5 * time.Minute) + for { + err := db.Ping() + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("postgres did not become ready within %s (last error: %v); on a contended machine this is start-up time, not the schema", + 5*time.Minute, err) + } + time.Sleep(200 * time.Millisecond) + } + return db, url +} + +// ledgerUpTo copies the proposed ledger's migrations through `highest` into a +// temporary directory, so a test can stand a database at an intermediate version +// and then apply one migration onto it. +// +// It reads `../migrations` directly rather than a git archive, which is the +// point: an in-place edit of an applied migration is what this shape exists to +// make observable, so the files under test have to be the working tree's. +func ledgerUpTo(t *testing.T, highest int) string { + t.Helper() + source, err := filepath.Abs("../migrations") + if err != nil { + t.Fatal(err) + } + into := t.TempDir() + for _, file := range listSQL(t, source) { + if versionOf(t, file) > highest { + continue + } + body, err := os.ReadFile(filepath.Join(source, file)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(into, file), body, 0o600); err != nil { + t.Fatal(err) + } + } + return into +} + +// stageInstallationParents creates the one organisation, two principals and +// scope node that an installation's foreign keys require. +func stageInstallationParents(t *testing.T, db *sql.DB, email, slug string) (org, agent, owner, node string) { + t.Helper() + var user string + mustQuery(t, db, ` + INSERT INTO public.users (uuid, primary_email) + VALUES (gen_random_uuid(), $1) RETURNING uuid::text`, &user, email) + mustQuery(t, db, ` + INSERT INTO public.organizations (id, name, slug, owner_id) + VALUES (gen_random_uuid(), $1, $1, $2::uuid) RETURNING id::text`, &org, slug, user) + // An agent principal needs an agent_identifier and an org (principals_agent_identifier_consistency, + // principals_org_scope); a service principal needs an org and no identifier. + mustQuery(t, db, ` + INSERT INTO public.principals (id, kind, display_name, org_id, agent_identifier) + VALUES (gen_random_uuid(), 'agent', 'staged agent', $1::uuid, 'acme.example/staged:1.0.0') + RETURNING id::text`, &agent, org) + mustQuery(t, db, ` + INSERT INTO public.principals (id, kind, display_name, org_id) + VALUES (gen_random_uuid(), 'service', 'staged owner', $1::uuid) RETURNING id::text`, &owner, org) + mustQuery(t, db, ` + INSERT INTO public.scope_nodes (id, org_id, scope_path, kind, label) + VALUES (gen_random_uuid(), $1::uuid, 'cutover'::public.ltree, 'solution', 'Cutover Solution') + RETURNING id::text`, &node, org) + return org, agent, owner, node +} + +func stageInstallation(t *testing.T, db *sql.DB, alias, createdAt, org, agent, owner, node string) string { + t.Helper() + var id string + // The columns migration 23 reads, on real parent rows. + mustQuery(t, db, ` + INSERT INTO public.installations + (org_id, agent_principal_id, solution_identifier, owner_principal_id, root_scope_node_id, status, created_at) + VALUES ($2::uuid, $3::uuid, $1, $4::uuid, $5::uuid, 'active', `+createdAt+`) + RETURNING id::text`, &id, alias, org, agent, owner, node) + return id +} + +func statusOf(t *testing.T, db *sql.DB, id string) string { + t.Helper() + var status string + mustQuery(t, db, `SELECT status FROM public.installations WHERE id = $1::uuid`, &status, id) + return status +} + +func targetOf(t *testing.T, db *sql.DB, id string) string { + t.Helper() + var target string + mustQuery(t, db, `SELECT COALESCE(target_id::text, '') FROM public.installations WHERE id = $1::uuid`, &target, id) + return target +} + +func mustQuery(t *testing.T, db *sql.DB, query string, into *string, args ...any) { + t.Helper() + if err := db.QueryRow(query, args...).Scan(into); err != nil { + t.Fatalf("%s: %v", summarize(query), err) + } +} + +// summarize names the failing statement in one line. It takes the first +// non-empty line rather than indexing into the split, which panicked on a +// single-line query and reported a nil-pointer index out of range in place of +// the database error it was called to show. +func summarize(query string) string { + for _, line := range strings.Split(query, "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/module/services/store/code/migration_23_target_liveness_race_test.go b/module/services/store/code/migration_23_target_liveness_race_test.go new file mode 100644 index 000000000..7403df50f --- /dev/null +++ b/module/services/store/code/migration_23_target_liveness_race_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" +) + +// `installations_target_must_be_live` takes `FOR UPDATE` on the solution target +// it reads, and the claim that comment makes is about CONCURRENCY: an install +// cannot slip past a withdrawal that has not committed yet. +// +// The claim is not provable by a single-transaction test. Reading the target +// without the lock also passes every serial test — the row is open, the trigger +// says so, the insert succeeds. What the lock changes is only visible with two +// transactions in flight: without it the inserting transaction reads the +// target's last committed version (open), the withdrawal commits, and both +// transactions succeed, leaving an ACTIVE installation of a withdrawn target — +// exactly the state the trigger exists to make unreachable. +// +// So this drives the two halves against each other on the real schema: +// +// 1. Transaction W closes the target with an UPDATE and does NOT commit. The +// UPDATE holds the row lock. +// 2. Transaction I inserts an active installation naming that target. The +// trigger's `FOR UPDATE` must BLOCK here rather than read the stale open row. +// 3. W commits. +// 4. I must then see the committed close and be REFUSED by name. +// +// Step 2 is where the lock is actually measured: the insert is asserted not to +// return while W is open. An FK's own lock would not produce that — PostgreSQL +// takes `FOR KEY SHARE` for a foreign key, and an UPDATE that touches no key +// column takes `FOR NO KEY UPDATE`, which does not conflict with it. The +// attribution is settled by mutation rather than by that argument: with +// `FOR UPDATE` removed from the function, step 2 returns immediately and the +// insert succeeds. +func TestMigration23TargetLivenessTriggerSerialisesAgainstAWithdrawal(t *testing.T) { + db, url := throwawayPostgres(t) + // The whole cutover, applied as a release applies it. Nothing is staged + // before 20 here: this is about the trigger the migration installs, not about + // its backfill. + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 23), url); err != nil { + t.Fatalf("apply migrations 1..23: %v", err) + } + + org, agent, owner, node := stageInstallationParents(t, db, "race@example.com", "race-co") + var target string + mustQuery(t, db, ` + INSERT INTO public.solution_targets (binding_id, solution_id, opened_generation) + VALUES ('acme.race.binding', 'race-alias', 1) RETURNING id::text`, &target) + + ctx := context.Background() + withdrawal, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = withdrawal.Rollback() }() + // A tombstone generation closes the target. This is the statement the + // reconciler runs, and it is what takes the row lock the trigger waits on. + if _, err := withdrawal.ExecContext(ctx, ` + UPDATE public.solution_targets + SET closed_generation = 2, closed_at = now() + WHERE id = $1::uuid`, target); err != nil { + t.Fatalf("close the target: %v", err) + } + + // The install, on its own connection, while the withdrawal is still open. + installed := make(chan error, 1) + go func() { + _, err := db.ExecContext(ctx, ` + INSERT INTO public.installations + (org_id, agent_principal_id, owner_principal_id, root_scope_node_id, target_id, status) + VALUES ($1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::uuid, 'active')`, + org, agent, owner, node, target) + installed <- err + }() + + // Not a timing heuristic about how fast PostgreSQL is: the insert either + // waits on a lock, in which case it cannot return at all until the + // withdrawal ends, or it does not take the lock and returns as soon as the + // trigger has read the row. Three seconds separates those two outcomes with + // a wide margin, and the failure message names which one happened. + select { + case err := <-installed: + t.Fatalf("the install did not wait for the withdrawal's row lock, so the trigger read a target that was already being closed (insert returned %v)", err) + case <-time.After(3 * time.Second): + } + + if err := withdrawal.Commit(); err != nil { + t.Fatalf("commit the withdrawal: %v", err) + } + + select { + case err := <-installed: + if err == nil { + t.Fatal("the install succeeded against a target whose withdrawal had committed: an ACTIVE installation of a withdrawn target is the state the trigger exists to make unreachable") + } + if !strings.Contains(err.Error(), "was withdrawn at generation 2") { + t.Fatalf("the install must be refused by the liveness trigger, naming the generation that withdrew the target; got %v", err) + } + case <-time.After(30 * time.Second): + t.Fatal("the install never returned after the withdrawal committed") + } + + // And it wrote nothing. A refusal that still left the row would be the same + // defect reached by a different path. + var rows int + if err := db.QueryRow(` + SELECT count(*) FROM public.installations WHERE target_id = $1::uuid`, target).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatalf("the refused install left %d installation row(s) naming the withdrawn target", rows) + } +} diff --git a/module/services/store/code/migration_26_cold_cutover_test.go b/module/services/store/code/migration_26_cold_cutover_test.go new file mode 100644 index 000000000..9e840bf96 --- /dev/null +++ b/module/services/store/code/migration_26_cold_cutover_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "database/sql" + "net/url" + "os" + "strings" + "testing" +) + +// Like the migration-22 replay, install the proposed ledger from zero, stage +// pre-cutover rows, then apply only the new version. The URL must name a fresh, +// disposable PostgreSQL 16+ cluster: migration 1 also requires absent app roles. +// +// This stops at version 26 on purpose, so the `runtime_boundary` assertions below +// read the column as it existed THERE. Migration 28 drops it — the boundary is +// derived from `declared_binding_id` at the head — and that does not weaken what +// this proves: the cutover had to withdraw rather than delete, and comparing the +// seed across migration 26 is the only thing that shows a delete. +func TestMigration26ColdCutoverFromZero(t *testing.T) { + raw := os.Getenv("ACCOUNTS_INSTALLER_TEST_DATABASE_URL") + if raw == "" { + t.Skip("ACCOUNTS_INSTALLER_TEST_DATABASE_URL is unset; PostgreSQL replay not run") + } + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") || + (parsed.Hostname() != "localhost" && parsed.Hostname() != "127.0.0.1") || + !strings.HasPrefix(parsed.Path, "/installer_test_") { + t.Fatal("requires a disposable loopback installer_test_ database") + } + db, err := sql.Open("postgres", raw) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var existing int + if err := db.QueryRow(`SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND c.relkind IN ('r','p')`).Scan(&existing); err != nil { + t.Fatal(err) + } + if existing != 0 { + t.Fatal("from-zero replay refuses a database containing application tables") + } + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 25), raw); err != nil { + t.Fatalf("apply migrations 1..25: %v", err) + } + t.Log("applied migrations 1..25 from zero") + execColdCutoverSQL(t, db, `INSERT INTO public.solution_targets (binding_id,solution_id,opened_generation) + VALUES ('acme.test.example','declared',1),('acme.test.removed','removed',1)`) + execColdCutoverSQL(t, db, `INSERT INTO public.solution_registrations + (solution_id,publisher,revision,frontend_revision,frontend_manifest,frontend_lease_expires_at, + backend_revision,backend_upstream,backend_service_alias,backend_lease_expires_at, + declared_binding_id,declared_generation,declared_release,declared_target_id) + VALUES ('runtime','solution:runtime',nextval('public.solution_registry_revision_sequence'), + 1,'{}',now()+interval '1 hour',1,'http://example.svc','runtime',now()+interval '1 hour',NULL,NULL,NULL,NULL), + ('declared','solution:declared',nextval('public.solution_registry_revision_sequence'), + 1,'{}',now()+interval '1 hour',1,'http://example.svc','declared',now()+interval '1 hour','acme.test.example',1,'acme/example@1.0.0',(SELECT id FROM public.solution_targets WHERE binding_id='acme.test.example'))`) + execColdCutoverSQL(t, db, `INSERT INTO public.solution_registrations + (solution_id,publisher,revision,tombstoned_at,declared_binding_id,declared_generation,declared_release,declared_target_id) + VALUES ('removed','solution:removed',nextval('public.solution_registry_revision_sequence'),now(),'acme.test.removed',2,'acme/example@1.0.0',(SELECT id FROM public.solution_targets WHERE binding_id='acme.test.removed'))`) + // The seed, read BEFORE the cutover. The whole point of withdrawing rather + // than deleting is that this value survives, so it has to be compared + // against itself across the migration rather than merely asserted non-empty. + var seedBefore string + if err := db.QueryRow(`SELECT runtime_boundary::text FROM public.solution_registrations + WHERE solution_id='runtime'`).Scan(&seedBefore); err != nil { + t.Fatalf("read the runtime registration's seed before the cutover: %v", err) + } + if seedBefore == "" { + t.Fatal("migration 17 must have seeded runtime_boundary; an empty seed makes the comparison below vacuous") + } + + if err := migrateStoreFrom("file://"+ledgerUpTo(t, 26), raw); err != nil { + t.Fatalf("apply migration 26: %v", err) + } + assertCount := func(query string, want int) { + t.Helper() + var got int + if err := db.QueryRow(query).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("query %s returned %d, want %d", query, got, want) + } + } + // The two lease columns BY NAME. `LIKE '%lease%'` also matches + // `declared_rELEASE`, so the loose pattern asserted that a column this + // migration is supposed to KEEP had been dropped — it reported 3 where it + // meant 0, and would have gone on failing for a reason unrelated to leases. + assertCount(`SELECT count(*) FROM information_schema.columns WHERE table_schema='public' + AND table_name='solution_registrations' + AND column_name IN ('frontend_lease_expires_at','backend_lease_expires_at')`, 0) + assertCount(`SELECT count(*) FROM pg_indexes WHERE schemaname='public' + AND tablename='solution_registrations' AND indexdef LIKE '%lease%'`, 0) + // WITHDRAWN, NOT DESTROYED. A runtime-registered row with no declared + // binding is a solution the new registry has not authorized — a registry + // state, not a row to delete. Deleting it would take its seed with it, and + // a later approval would mint a fresh one, making every run that solution + // had already admitted unreachable. + assertCount(`SELECT count(*) FROM public.solution_registrations WHERE solution_id='runtime'`, 1) + assertCount(`SELECT count(*) FROM public.solution_registrations WHERE solution_id='runtime' + AND tombstoned_at IS NOT NULL AND declared_binding_id IS NULL + AND frontend_revision IS NULL AND backend_revision IS NULL`, 1) + + // AND THE SEED IS THE SAME ONE. This is the assertion the delete silently + // broke: it compiles, it passes every other check here, and the only thing + // that shows the loss is comparing the value across the migration. + var seedAfter string + if err := db.QueryRow(`SELECT runtime_boundary::text FROM public.solution_registrations + WHERE solution_id='runtime'`).Scan(&seedAfter); err != nil { + t.Fatalf("read the seed after the cutover: %v", err) + } + if seedAfter != seedBefore { + t.Fatalf("the cutover changed a solution's runtime boundary seed: %s -> %s; "+ + "every run it already admitted is now unreachable under its old boundary", + seedBefore, seedAfter) + } + + // A re-approval through the declared path reactivates the SAME seed. + execColdCutoverSQL(t, db, `UPDATE public.solution_registrations + SET declared_binding_id='acme.test.removed', declared_generation=3, + declared_release='acme/example@1.0.0', + declared_target_id=(SELECT id FROM public.solution_targets WHERE binding_id='acme.test.removed'), + tombstoned_at=NULL + WHERE solution_id='runtime'`) + var seedAfterApproval string + if err := db.QueryRow(`SELECT runtime_boundary::text FROM public.solution_registrations + WHERE solution_id='runtime'`).Scan(&seedAfterApproval); err != nil { + t.Fatalf("read the seed after re-approval: %v", err) + } + if seedAfterApproval != seedBefore { + t.Fatalf("approval through the declared path changed the seed: %s -> %s", seedBefore, seedAfterApproval) + } + assertCount(`SELECT count(*) FROM public.solution_registrations WHERE solution_id='declared' + AND declared_binding_id='acme.test.example' AND frontend_revision IS NULL AND backend_revision IS NULL`, 1) + assertCount(`SELECT count(*) FROM public.solution_registrations WHERE solution_id='removed' AND tombstoned_at IS NOT NULL`, 1) + // The retained projection accepts whole observations without a lease, while + // partial observations and undeclared presence fail at the database boundary. + execColdCutoverSQL(t, db, `UPDATE public.solution_registrations SET frontend_revision=10,frontend_manifest='{}', + backend_revision=10,backend_upstream='http://example.svc',backend_service_alias='declared' + WHERE solution_id='declared'`) + for _, query := range []string{ + `UPDATE public.solution_registrations SET frontend_manifest=NULL WHERE solution_id='declared'`, + `UPDATE public.solution_registrations SET backend_upstream=NULL WHERE solution_id='declared'`, + `INSERT INTO public.solution_registrations (solution_id,publisher,revision) VALUES ('runtime','solution:runtime',11)`, + } { + if _, err := db.Exec(query); err == nil { + t.Fatalf("invalid registry state accepted: %s", query) + } + } + t.Log("migration 26 withdrew the runtime registration keeping its seed, dropped the lease columns, and kept declared presence, tombstones and the whole-half constraints") +} + +func execColdCutoverSQL(t *testing.T, db *sql.DB, statement string) { + t.Helper() + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } +} diff --git a/module/services/store/migrations/20_solution_host_bindings.down.sql b/module/services/store/migrations/20_solution_host_bindings.down.sql new file mode 100644 index 000000000..e90cec5f8 --- /dev/null +++ b/module/services/store/migrations/20_solution_host_bindings.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS public.solution_registrations_declared_binding; + +ALTER TABLE public.solution_registrations + DROP CONSTRAINT IF EXISTS solution_registrations_declared_binding_id_check, + DROP CONSTRAINT IF EXISTS solution_registrations_declared_generation_check, + DROP CONSTRAINT IF EXISTS solution_registrations_declared_whole; + +ALTER TABLE public.solution_registrations + DROP COLUMN IF EXISTS declared_release, + DROP COLUMN IF EXISTS declared_generation, + DROP COLUMN IF EXISTS declared_binding_id; + +DROP TABLE IF EXISTS public.solution_host_bindings; diff --git a/module/services/store/migrations/20_solution_host_bindings.up.sql b/module/services/store/migrations/20_solution_host_bindings.up.sql new file mode 100644 index 000000000..6ee2b71e0 --- /dev/null +++ b/module/services/store/migrations/20_solution_host_bindings.up.sql @@ -0,0 +1,197 @@ +-- Declared solution presence: the host's durable record of the +-- SolutionHostBinding documents delivery hands it (issue #952). +-- +-- Today a solution becomes present because its runtime registers itself. A +-- binding document is the opposite record: delivery declares which solution +-- runs on this host, at a generation, and the host reconciles towards it. The +-- document is desired state and carries no observation; what the host did about +-- it is applied state, and it lives here. +-- +-- One row per binding ID, which identifies one deployment instance. The row is +-- never deleted: removal is a tombstone GENERATION, so applied_removed is true +-- and the generation stays on the record. That is what makes a replayed or +-- late-arriving older generation refusable after a removal, and what tells +-- "declared absent" apart from "no document was delivered". +-- +-- The three column groups are the three facts an operator must be able to tell +-- apart. desired_* is the newest generation delivery has shown this host. +-- applied_* is the generation the host reconciled, and the registry record it +-- reconciled into. pending_reason is why a desired generation that is not the +-- applied one did not pass. Observed state is not here at all: it is the lease +-- and the endpoints on public.solution_registrations, reported by the runtime. +-- +-- applied_document is kept, not just its digest, because the host must keep +-- serving the last generation that passed every check until a new one does. A +-- digest alone would tell it that the applied generation is not the desired one +-- and nothing about what it is still running. + +CREATE TABLE public.solution_host_bindings ( + binding_id text NOT NULL, + -- The coordinate the applied (or, before a first apply, the desired) + -- document targets. A document naming another coordinate is refused before + -- it reaches this table, so this records which host's record this is rather + -- than deciding it. + host_coordinate text NOT NULL, + host_component text NOT NULL, + + -- Desired: the newest generation read from the mount, whether or not it + -- passed. Whole or absent. + desired_generation bigint, + desired_digest text, + desired_document text, + desired_seen_at timestamp with time zone, + + -- Applied: the generation this host reconciled. Whole or absent. + applied_generation bigint, + applied_digest text, + applied_document text, + applied_removed boolean DEFAULT false NOT NULL, + -- The route aliases the applied generation holds. They are held until a + -- later generation releases them or a tombstone withdraws them, which is + -- exactly what core's solutionhost.Applied carries. + applied_routes text[] DEFAULT '{}'::text[] NOT NULL, + -- The public.solution_registrations key the applied generation reconciled + -- into. A tombstone generation needs it to know which record to withdraw, + -- because a removed generation declares no route to derive it from. + applied_solution_id text, + -- publisher/name@version of the applied generation, so a heartbeat that + -- would replace the release can be refused against a declared record. + applied_release text, + -- The ownership domain the applied generation declared. + -- + -- It is NOT decoration beside host_coordinate. The domain is what says who + -- may change this record: core refuses a later generation for this binding + -- that arrives under a different one, which is what stops an accepted + -- delivery taking over a binding another delivery owns. A host that did not + -- persist it would hand core an applied state with no domain, and core + -- refuses THAT too — so the symptom is not a widened check, it is a + -- reconciler that applies its first generation and then refuses the whole + -- set on every pass afterwards. + applied_domain text, + applied_at timestamp with time zone, + + -- Pending: why the desired generation is not the applied one. Whole or + -- absent, and cleared by the pass that applies the desired generation. + pending_reason text, + pending_since timestamp with time zone, + + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + + -- core's bindingPattern, which admits a ULID, a UUID and a dotted slug and + -- excludes anything that would need escaping in a path or a label. + CONSTRAINT solution_host_bindings_binding_id_check + CHECK ((binding_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,126}[A-Za-z0-9]$'::text)), + -- "base" is composition.ValidateCollisions' reserved-namespace exemption + -- sentinel and core refuses it as a binding ID; refusing it here too stops a + -- hand-written row from inheriting the exemption. + CONSTRAINT solution_host_bindings_binding_id_not_reserved + CHECK ((binding_id <> 'base'::text)), + CONSTRAINT solution_host_bindings_host_coordinate_check + CHECK ((host_coordinate <> ''::text)), + CONSTRAINT solution_host_bindings_host_component_check + CHECK ((host_component <> ''::text)), + CONSTRAINT solution_host_bindings_desired_generation_check + CHECK ((desired_generation > 0)), + CONSTRAINT solution_host_bindings_applied_generation_check + CHECK ((applied_generation > 0)), + CONSTRAINT solution_host_bindings_desired_digest_check + CHECK ((desired_digest ~ '^sha256:[0-9a-f]{64}$'::text)), + CONSTRAINT solution_host_bindings_applied_digest_check + CHECK ((applied_digest ~ '^sha256:[0-9a-f]{64}$'::text)), + -- Whole or absent, both groups. A half-written generation would be a state + -- no document ever described: a digest with no generation cannot be + -- compared, and a generation with no digest cannot detect a rewrite. + CONSTRAINT solution_host_bindings_desired_whole + CHECK ((num_nonnulls(desired_generation, desired_digest, desired_document, desired_seen_at) = ANY (ARRAY[0, 4]))), + CONSTRAINT solution_host_bindings_applied_whole + CHECK ((num_nonnulls(applied_generation, applied_digest, applied_document, applied_solution_id, applied_release, applied_domain, applied_at) = ANY (ARRAY[0, 7]))), + -- A domain is only meaningful on an applied generation, and an applied + -- generation without one is the state core refuses. The whole-or-absent + -- group above already pairs them; this names the non-empty rule, because + -- '' passes num_nonnulls and is exactly the value core rejects. + CONSTRAINT solution_host_bindings_applied_domain_check + CHECK ((applied_domain <> ''::text)), + -- A tombstone holds no alias; core asserts the same invariant when it reads + -- a host's applied state back, and would refuse the whole pass rather than + -- this one row. + CONSTRAINT solution_host_bindings_tombstone_holds_no_route + CHECK (((NOT applied_removed) OR (cardinality(applied_routes) = 0))), + -- applied_removed is only meaningful for an applied generation. + CONSTRAINT solution_host_bindings_removed_needs_applied + CHECK (((NOT applied_removed) OR (applied_generation IS NOT NULL))), + -- A pending refusal is whole or absent: a reason with no instant is not + -- something an operator can act on, and the reverse explains nothing. Two + -- IS NULL tests compared are never NULL, so this cannot pass vacuously. + CONSTRAINT solution_host_bindings_pending_whole + CHECK (((pending_reason IS NULL) = (pending_since IS NULL))) +); + +ALTER TABLE ONLY public.solution_host_bindings + ADD CONSTRAINT solution_host_bindings_pkey PRIMARY KEY (binding_id); + +-- One live binding per registry key. Route aliases are unique within a host and +-- the resolved alias IS the registry key, so core's collision check already +-- refuses a second claimant before a generation applies; this index is what +-- makes that hold when two replicas reconcile the same pass concurrently. +-- Partial, because a tombstoned binding keeps the key it last applied so its +-- removal stays attributable, and that must not block a later binding claiming +-- the alias the tombstone released. +CREATE UNIQUE INDEX solution_host_bindings_live_solution + ON public.solution_host_bindings USING btree (applied_solution_id) + WHERE ((applied_solution_id IS NOT NULL) AND (NOT applied_removed)); + +-- The reconciler reads the whole table on every pass (one host holds tens of +-- bindings at most), so no listing index is needed. This one serves the +-- heartbeat path, which resolves "is this solution declared?" by key. +CREATE INDEX solution_host_bindings_solution + ON public.solution_host_bindings USING btree (applied_solution_id) + WHERE (applied_solution_id IS NOT NULL); + +-- Control-plane only, exactly like public.solution_registrations: no tenant +-- column, no row-level security, and request traffic has no direct access. +GRANT SELECT,INSERT,UPDATE ON TABLE public.solution_host_bindings TO app_control_plane; + +-- The registration record learns which binding declared it. +-- +-- This is the link the mixed window is enforced through. Until the runtimes +-- stop self-registering, both paths write public.solution_registrations: the +-- reconciler applies declared presence, and a runtime heartbeats observations. +-- A heartbeat for a DECLARED record may only refresh what the declaration does +-- not own — the lease, the manifest, the upstream address. It may not create +-- presence, replace the release, repoint a route or erase a tombstone. +-- +-- The three columns live on the registration row rather than being looked up in +-- solution_host_bindings because the heartbeat path already row-locks this row: +-- reading the declaration from the locked row is what serializes a heartbeat +-- against a concurrent apply. A second table read would leave a window in which +-- a heartbeat decided it was undeclared and then wrote. +-- +-- NULL declared_binding_id is an undeclared record, and every existing row is +-- one: the heartbeat path for those is unchanged, which is what lets this land +-- before any runtime changes. +ALTER TABLE public.solution_registrations + ADD COLUMN declared_binding_id text, + ADD COLUMN declared_generation bigint, + ADD COLUMN declared_release text; + +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_declared_whole + CHECK ((num_nonnulls(declared_binding_id, declared_generation, declared_release) = ANY (ARRAY[0, 3]))), + ADD CONSTRAINT solution_registrations_declared_generation_check + CHECK ((declared_generation > 0)), + ADD CONSTRAINT solution_registrations_declared_binding_id_check + CHECK ((declared_binding_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,126}[A-Za-z0-9]$'::text)); + +-- A registration is declared by at most one binding, and a binding declares at +-- most one LIVE registration. +-- +-- Partial on the tombstone, not just on NULL. A binding whose generation renames +-- its route withdraws the record it held and declares the one it moved to, in a +-- single transaction, and the withdrawal deliberately KEEPS its declaration — that +-- is what makes the removal hold against a late heartbeat. So that binding names +-- two rows for an instant and forever after: one live, and one tombstoned for +-- every alias it has ever released. Only the live one may be unique. +CREATE UNIQUE INDEX solution_registrations_declared_binding + ON public.solution_registrations USING btree (declared_binding_id) + WHERE ((declared_binding_id IS NOT NULL) AND (tombstoned_at IS NULL)); diff --git a/module/services/store/migrations/21_solution_targets.down.sql b/module/services/store/migrations/21_solution_targets.down.sql new file mode 100644 index 000000000..428fcb00e --- /dev/null +++ b/module/services/store/migrations/21_solution_targets.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.solution_targets; diff --git a/module/services/store/migrations/21_solution_targets.up.sql b/module/services/store/migrations/21_solution_targets.up.sql new file mode 100644 index 000000000..ad596ac0a --- /dev/null +++ b/module/services/store/migrations/21_solution_targets.up.sql @@ -0,0 +1,105 @@ +-- An immutable, never-reused identity for the thing an organisation installs. +-- +-- Today `installations.solution_identifier` is free text, and the registry key a +-- solution is addressed by is its route alias, which is deliberately REUSABLE: a +-- tombstoned alias may be claimed by another binding +-- (`solution_host_bindings`, migration 20). Those two facts compose into a +-- transfer of consent: +-- +-- 1. Org A installs the binding behind alias `reports` and grants a team access. +-- 2. That binding is withdrawn by a tombstone generation. +-- 3. A DIFFERENT binding takes the alias `reports`. +-- 4. The installation still names `reports`. +-- 5. The new binding inherits org A's installation and its team exposure, +-- with no administrator having acted. +-- +-- A foreign key to the registry would not fix this. It guarantees referential +-- integrity, not continuity of authorised identity, and pointing it at the alias +-- row would make the inheritance formal rather than accidental. Core states the +-- intended rule plainly: a second instance of a solution "gets its own binding ID +-- and inherits nothing from this one — no route, no generation history, no +-- installation". +-- +-- So the thing an organisation installs is given its own identity here. A +-- solution target is one continuous period of one binding's presence on this +-- host. It is minted when a present generation is applied for a binding that has +-- no live target, and it is closed when that binding's tombstone generation is +-- applied. It is never reused: a binding that is withdrawn and later delivered +-- again gets a NEW target, because the tombstone ended the presence an +-- administrator consented to, and consenting again is a dynamic act. +-- +-- The route alias lives here too, and may move between generations. It is a +-- property of the target, never its identity. + +CREATE TABLE public.solution_targets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + -- The binding whose presence this target records. Not unique on its own: a + -- binding withdrawn and re-presented has one row per period. + binding_id text NOT NULL, + -- What the host routes this target on, as of the applied generation. It moves + -- with a generation that renames it, which is exactly why it is not the + -- identity. + solution_id text NOT NULL, + -- The generation that opened this target, and the one that closed it. Closing + -- is a tombstone generation, never a deletion. + opened_generation bigint NOT NULL, + closed_generation bigint, + opened_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + closed_at timestamp with time zone, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT solution_targets_binding_id_check + CHECK ((binding_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,126}[A-Za-z0-9]$'::text)), + CONSTRAINT solution_targets_binding_id_not_reserved + CHECK ((binding_id <> 'base'::text)), + CONSTRAINT solution_targets_solution_id_check + CHECK ((solution_id ~ '^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$'::text) AND (length(solution_id) <= 128)), + CONSTRAINT solution_targets_opened_generation_check CHECK ((opened_generation > 0)), + -- A close is whole or absent, and never precedes the open it closes. Two + -- IS NULL tests compared are never NULL, so this cannot pass vacuously. + CONSTRAINT solution_targets_close_whole + CHECK (((closed_generation IS NULL) = (closed_at IS NULL))), + CONSTRAINT solution_targets_close_follows_open + CHECK (((closed_generation IS NULL) OR (closed_generation >= opened_generation))) +); + +ALTER TABLE ONLY public.solution_targets + ADD CONSTRAINT solution_targets_pkey PRIMARY KEY (id); + +-- One live target per binding. A binding cannot be present twice at once, and +-- this is what makes "mint a target if the binding has no live one" a race-free +-- read-decide-write under the reconciler's row lock. +CREATE UNIQUE INDEX solution_targets_live_binding + ON public.solution_targets USING btree (binding_id) + WHERE (closed_generation IS NULL); + +-- One live target per route alias, for the same reason migration 20's index +-- exists on the binding row: core refuses the collision before a generation +-- applies, and this is the durable backstop when two replicas reconcile one pass. +CREATE UNIQUE INDEX solution_targets_live_solution + ON public.solution_targets USING btree (solution_id) + WHERE (closed_generation IS NULL); + +CREATE INDEX solution_targets_binding_history + ON public.solution_targets USING btree (binding_id, opened_generation DESC); + +-- Control-plane only, like the other presence relations: no tenant column, no +-- row-level security, and request traffic has no direct access. Never deleted — +-- a closed target is the evidence that an installation's consent ended. +GRANT SELECT,INSERT,UPDATE ON TABLE public.solution_targets TO app_control_plane; + +-- `installations` is deliberately NOT touched here. +-- +-- Pointing an installation at a target means dropping the free-text +-- `solution_identifier` it names today, and that column is read by the +-- installations store (a POSITIONAL scan), the `SolutionEntitlement` contract, +-- the gateway's entitlement JSON and the frontend projections that join on it. +-- Dropping it in the same migration as the table it would point at would land a +-- schema no deployed reader can serve, which is the one thing a cutover +-- migration must not do: it has to arrive with its readers, not before them. +-- +-- So this migration establishes the identity and nothing else. The installation +-- side — the immutable `target_id`, the revocation of historical rows whose +-- target cannot be established, and dropping `solution_identifier` — lands with +-- the reader changes that make it serveable. diff --git a/module/services/store/migrations/22_solution_generation_history.down.sql b/module/services/store/migrations/22_solution_generation_history.down.sql new file mode 100644 index 000000000..a3dc6cde4 --- /dev/null +++ b/module/services/store/migrations/22_solution_generation_history.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.solution_generation_history; diff --git a/module/services/store/migrations/22_solution_generation_history.up.sql b/module/services/store/migrations/22_solution_generation_history.up.sql new file mode 100644 index 000000000..2549e064f --- /dev/null +++ b/module/services/store/migrations/22_solution_generation_history.up.sql @@ -0,0 +1,89 @@ +-- The generation history: one row per generation this host DECIDED about. +-- +-- The audit trail the owner asked for after the registration-event incident, and +-- the source for the Catalogue's "last N generations per component with +-- applied/refused/tombstoned status". +-- +-- It cannot be derived from anything that already exists. +-- public.solution_host_bindings holds exactly ONE desired and ONE applied +-- generation, and its pending_reason is the CURRENT refusal, overwritten on every +-- pass. So a refusal that is superseded leaves no trace, and the sequence of +-- generations a component moved through is unrecoverable the moment the next one +-- arrives. +-- +-- It is also why this lands before the screen that reads it rather than with it: +-- a history relation added later starts EMPTY. If the trail is to cover the period +-- from the moment the new host exists, the recording has to begin when the +-- reconciler starts deciding, not when the Catalogue ships. +-- +-- Append-only by grant: the control plane may SELECT and INSERT and nothing else. +-- A decision is a fact about the past, and a trail whose rows can be edited is not +-- a trail. Retention is a separate concern and will drop whole ranges rather than +-- rewrite rows. + +CREATE TABLE public.solution_generation_history ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + -- Which component this decision was about. Not a foreign key: the trail + -- outlives the binding row, and a decision about a binding whose first + -- generation was refused has no row to reference. + binding_id text NOT NULL, + -- The target this decision applied into, when it reached one. Absent for a + -- refusal, and for a tombstone of a binding that never applied. + target_id uuid, + generation bigint NOT NULL, + -- The canonical digest of the document decided about, so two decisions at one + -- generation number are distinguishable — which is the rewritten-generation + -- case the host alarms on. + digest text NOT NULL, + -- What the host decided. `current` is recorded deliberately: a re-read that + -- changed nothing is still evidence the document was present and unchanged at + -- that moment, which is what distinguishes "delivery stopped" from "delivery + -- kept saying the same thing". + decision text NOT NULL, + -- Why, for a refusal. Carries the same text the binding's pending_reason + -- showed at the time, so a superseded refusal survives. + reason text, + -- The release the generation declared, for reading the trail without joining + -- back to a document that may since have been replaced. + release text, + -- The registry revision an apply produced, so a reader can line this trail up + -- against the registry's own revision sequence. + registry_revision bigint, + decided_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT solution_generation_history_binding_id_check + CHECK ((binding_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,126}[A-Za-z0-9]$'::text)), + CONSTRAINT solution_generation_history_generation_check CHECK ((generation > 0)), + CONSTRAINT solution_generation_history_digest_check + CHECK ((digest ~ '^sha256:[0-9a-f]{64}$'::text)), + CONSTRAINT solution_generation_history_decision_check + CHECK ((decision = ANY (ARRAY['applied'::text, 'current'::text, 'refused'::text, 'tombstoned'::text]))), + -- A refusal explains itself, and nothing else carries a reason. The + -- comparison of two IS NULL tests is never NULL, so this cannot pass + -- vacuously. + CONSTRAINT solution_generation_history_reason_explains_refusal + CHECK (((decision = 'refused'::text) = (reason IS NOT NULL))) +); + +ALTER TABLE ONLY public.solution_generation_history + ADD CONSTRAINT solution_generation_history_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.solution_generation_history + ADD CONSTRAINT solution_generation_history_target_id_fkey + FOREIGN KEY (target_id) REFERENCES public.solution_targets(id); + +-- The Catalogue's read: the last N decisions for one component, newest first. +CREATE INDEX solution_generation_history_component + ON public.solution_generation_history USING btree (binding_id, decided_at DESC); + +-- One decision per (binding, generation, digest, decision): a pass that re-reads +-- an unchanged document must not append a row per pass, or a poll every thirty +-- seconds becomes the registration-event incident again in a different table. +-- A genuinely new decision about the same generation differs in digest (a rewrite) +-- or in decision (refused then applied), so it still records. +CREATE UNIQUE INDEX solution_generation_history_one_per_decision + ON public.solution_generation_history USING btree (binding_id, generation, digest, decision); + +-- Append-only: SELECT and INSERT, never UPDATE or DELETE. A decision is a fact +-- about the past. +GRANT SELECT,INSERT ON TABLE public.solution_generation_history TO app_control_plane; diff --git a/module/services/store/migrations/23_installation_target_identity.down.sql b/module/services/store/migrations/23_installation_target_identity.down.sql new file mode 100644 index 000000000..f0ebd5927 --- /dev/null +++ b/module/services/store/migrations/23_installation_target_identity.down.sql @@ -0,0 +1,49 @@ +-- Restores the schema shape the baseline left, and nothing more. +-- +-- Said plainly, because a down migration that looks complete and is not is worse +-- than one that says so: the route alias each installation named is NOT +-- recoverable here. It was dropped with the column, and the only record of which +-- alias a target served is the target row, whose alias may have moved since. So +-- `solution_identifier` comes back empty for every row, and the status this +-- migration revoked stays revoked. +-- +-- That is the honest consequence of a destructive cutover, and it is why the +-- forward direction records `revoked_reason`: the reverse cannot reconstruct +-- consent, so the forward direction has to leave evidence of what it ended. + +ALTER TABLE public.solution_registrations + DROP CONSTRAINT IF EXISTS solution_registrations_declared_whole; +ALTER TABLE public.solution_registrations + DROP COLUMN IF EXISTS declared_target_id; +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_declared_whole + CHECK ((num_nonnulls(declared_binding_id, declared_generation, declared_release) = ANY (ARRAY[0, 3]))); + +DROP TRIGGER IF EXISTS installations_target_live_on_update ON public.installations; +DROP TRIGGER IF EXISTS installations_target_live_on_insert ON public.installations; +DROP FUNCTION IF EXISTS public.installations_target_must_be_live(); + +DROP TRIGGER IF EXISTS installations_target_immutable ON public.installations; +DROP FUNCTION IF EXISTS public.installations_target_is_immutable(); + +DROP INDEX IF EXISTS public.idx_installations_target; +DROP INDEX IF EXISTS public.idx_installations_active_target; + +ALTER TABLE public.installations + DROP CONSTRAINT IF EXISTS installations_revoked_reason_only_when_revoked, + DROP CONSTRAINT IF EXISTS installations_active_requires_target, + DROP CONSTRAINT IF EXISTS installations_target_id_fkey; + +ALTER TABLE public.installations + ADD COLUMN solution_identifier text NOT NULL DEFAULT ''; + +ALTER TABLE public.installations + ALTER COLUMN solution_identifier DROP DEFAULT; + +ALTER TABLE public.installations + DROP COLUMN revoked_reason, + DROP COLUMN target_id; + +CREATE UNIQUE INDEX idx_installations_active_solution + ON public.installations USING btree (org_id, solution_identifier) + WHERE (status = 'active'::text); diff --git a/module/services/store/migrations/23_installation_target_identity.up.sql b/module/services/store/migrations/23_installation_target_identity.up.sql new file mode 100644 index 000000000..e447d2843 --- /dev/null +++ b/module/services/store/migrations/23_installation_target_identity.up.sql @@ -0,0 +1,294 @@ +-- An installation names the immutable target it was consented to, never the +-- reusable route alias. This is the destructive half migration 21 deferred, and +-- it arrives with its readers. +-- +-- The hole it closes, which existed for as long as `solution_identifier` was the +-- join key: +-- +-- 1. Org A installs the binding behind alias `reports`, and a team is granted +-- access at the installation's authority-root scope node. +-- 2. That binding is withdrawn by a tombstone generation. +-- 3. A DIFFERENT binding claims the alias `reports`. +-- 4. The installation still names the text `reports`. +-- 5. The proxy admits A's viewers to the new binding and forwards their +-- bearers to it, and A's team grant exposes it — with no administrator +-- having acted. +-- +-- A rename is the same defect from the other side: the installation keeps the +-- old text, so A silently loses access and whoever next claims the old alias +-- inherits A's installation. +-- +-- `target_id` is a foreign key to `solution_targets`, one row per continuous +-- period of one binding's presence (migration 21). It is never reused, so +-- inheritance is not expressible: the withdrawn binding's target is CLOSED and +-- the replacement's is a different row. +-- +-- The alias is not stored here at all. It lives on the target, where a +-- generation may move it, and the gateway resolves alias -> target through the +-- applied-presence snapshot it already holds. That deliberately keeps +-- `solution_targets` control-plane only: joining it into the request-path +-- installation listing would have required granting request traffic SELECT over +-- every organisation's presence state to answer a question about one. + +ALTER TABLE public.installations + ADD COLUMN target_id uuid, + -- Why a row is revoked, when it was revoked by something other than an + -- administrator uninstalling. The cutover below is the first writer; a + -- tenancy narrowing that withdraws an organisation's installation is the + -- second. Without it, a revoked row cannot be told from an uninstall. + ADD COLUMN revoked_reason text; + +-- The deterministic disposition for rows that predate the target identity. +-- +-- `installations` FORCEs row-level security and its policies admit only the +-- request and control-plane roles, so the owner running this migration matches +-- zero rows through them. RLS is lifted for these two statements and restored +-- immediately after, exactly as migrations 2 and 14 do. +ALTER TABLE public.installations NO FORCE ROW LEVEL SECURITY; + +-- An active installation whose named alias is served by exactly one LIVE target +-- is carried over to that target. "Exactly one" is a property of the schema +-- rather than a hope: `solution_targets_live_solution` is a unique index over +-- `solution_id` where the target is open, so this join can match at most one row. +-- The alias alone is NOT enough to carry consent over, and matching on it alone +-- performs exactly the transfer the next statement's comment says this migration +-- exists to prevent. +-- +-- A route alias is a REUSABLE property of a target, never its identity. If a +-- binding was withdrawn and a different one later took the same alias, the "one +-- live target serving this alias" at migration time belongs to the SECOND +-- binding — and an installation consented to the first would silently become an +-- installation of the second. Same alias, different solution, consent moved +-- without anyone acting. +-- +-- `i.created_at >= t.opened_at` is what closes it: the installation must have +-- been created during the period this target has been open. An installation that +-- predates the target cannot have consented to it, so it falls through to the +-- revocation below rather than being carried across a period boundary. +-- +-- This is strictly narrower than the alias match, so it can only ever revoke +-- more — which is the right direction for a disposition that cannot be reviewed +-- case by case. +UPDATE public.installations AS i + SET target_id = t.id + FROM public.solution_targets AS t + WHERE t.closed_generation IS NULL + AND t.solution_id = i.solution_identifier + AND i.created_at >= t.opened_at + AND i.status = 'active'; + +-- Everything else is REVOKED, with the reason recorded. +-- +-- This is the fail-closed disposition and it is deliberately not a choice +-- between two reasonable answers. An active installation whose alias no live +-- target serves is one of exactly three things: a solution that was never +-- declared (so nothing on this host can establish what was consented to), one +-- that was withdrawn (so the consent already ended), or one whose alias now +-- belongs to a different binding (so carrying it over would commit the very +-- transfer this migration exists to prevent). Keeping it active would mean +-- guessing, and the guess that fails is the one that grants. +-- +-- A revoked installation is not a deletion: the row, its authority-root scope +-- node and its audit trail all remain, so re-installing is an administrator's +-- act on a record that can be read. +UPDATE public.installations + SET status = 'revoked', + revoked_at = now(), + revoked_reason = 'no live solution target served this installation''s route alias at the installation-identity cutover' + WHERE status = 'active' + AND target_id IS NULL; + +ALTER TABLE public.installations FORCE ROW LEVEL SECURITY; + +ALTER TABLE public.installations + ADD CONSTRAINT installations_target_id_fkey + FOREIGN KEY (target_id) REFERENCES public.solution_targets(id); + +-- Revoked history keeps `target_id` NULL when none could be established, so the +-- column cannot be NOT NULL. The invariant that matters is narrower and is the +-- one a reader depends on: an ACTIVE installation always names a target. +ALTER TABLE public.installations + ADD CONSTRAINT installations_active_requires_target + CHECK (((status <> 'active') OR (target_id IS NOT NULL))); + +-- A revoked row states why. Two IS NULL tests compared are never NULL, so this +-- cannot pass vacuously on a row that set neither. +ALTER TABLE public.installations + ADD CONSTRAINT installations_revoked_reason_only_when_revoked + CHECK (((revoked_reason IS NULL) OR (status = 'revoked'))); + +-- The free-text identifier goes. Its unique index goes with it: uniqueness is +-- now per target, which is what makes re-installing a withdrawn solution a new +-- row rather than a collision with the installation whose consent ended. +DROP INDEX IF EXISTS public.idx_installations_active_solution; + +ALTER TABLE public.installations + DROP COLUMN solution_identifier; + +CREATE UNIQUE INDEX idx_installations_active_target + ON public.installations USING btree (org_id, target_id) + WHERE (status = 'active'::text); + +CREATE INDEX idx_installations_target + ON public.installations USING btree (target_id); + +-- `target_id` is IMMUTABLE once set. A column with a foreign key can still be +-- repointed by any writer holding UPDATE, and repointing it is precisely the +-- transfer of consent the column exists to make impossible — so the rule is +-- enforced below every writer rather than in each one. +-- +-- Clearing it is refused for the same reason: a NULL target on a row that had +-- one would let the next writer set a different one. +CREATE OR REPLACE FUNCTION public.installations_target_is_immutable() +RETURNS trigger +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public, pg_temp +AS $$ +BEGIN + IF OLD.target_id IS NOT NULL AND (NEW.target_id IS NULL OR NEW.target_id <> OLD.target_id) THEN + RAISE EXCEPTION + 'installation % names solution target %, which is immutable: a replacement solution needs its own installation', + OLD.id, OLD.target_id + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER installations_target_immutable + BEFORE UPDATE ON public.installations + FOR EACH ROW + EXECUTE FUNCTION public.installations_target_is_immutable(); + +-- An ACTIVE installation's target must be LIVE. +-- +-- The business layer resolves the target before the install transaction — it has +-- to, because `solution_targets` is control-plane only and the install runs in a +-- tenant transaction — and a resolve-then-insert cannot be race-free on its own: +-- the tombstone that closes the target may be applied in between. So the rule is +-- enforced here, where the insert and the target row are in one transaction and +-- the reconciler's row lock orders them. +-- +-- SECURITY DEFINER, deliberately, and this is the narrowest form it can take: the +-- request role holds no grant on `solution_targets`, so an invoker-rights +-- function would read zero rows and the check would pass for every closed +-- target — a denied read printing as an empty table is the failure mode this +-- avoids. The function reads ONE row by primary key and either raises or does +-- not; it returns nothing, writes nothing, and takes no caller-supplied SQL. +CREATE OR REPLACE FUNCTION public.installations_target_must_be_live() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, pg_temp +AS $$ +DECLARE + closed bigint; + found boolean; +BEGIN + IF NEW.status <> 'active' OR NEW.target_id IS NULL THEN + RETURN NEW; + END IF; + -- FOR UPDATE, because reading the target without locking it lets this + -- insert race a withdrawal: the trigger reads "open", a concurrent + -- transaction closes the target, and both commit — leaving an ACTIVE + -- installation of a withdrawn target, which is the state the trigger exists + -- to make unreachable. + -- + -- Withdrawal closes a target with an UPDATE, which takes the same row lock, + -- so the two serialize: whichever reaches the row first wins and the other + -- sees its committed result. Locking the target rather than the + -- installation is what makes this work — the two transactions touch + -- different installation rows and would never contend there. + SELECT t.closed_generation, TRUE INTO closed, found + FROM public.solution_targets t + WHERE t.id = NEW.target_id + FOR UPDATE; + IF NOT COALESCE(found, FALSE) THEN + RAISE EXCEPTION 'solution target % does not exist', NEW.target_id + USING ERRCODE = 'foreign_key_violation'; + END IF; + IF closed IS NOT NULL THEN + RAISE EXCEPTION + 'solution target % was withdrawn at generation %, so it cannot be installed: a replacement presence has its own target', + NEW.target_id, closed + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.installations_target_must_be_live() FROM PUBLIC; + +CREATE TRIGGER installations_target_live_on_insert + BEFORE INSERT ON public.installations + FOR EACH ROW + EXECUTE FUNCTION public.installations_target_must_be_live(); + +-- Also on UPDATE, so reactivating a revoked installation whose target has since +-- been withdrawn is refused by the same rule rather than by whichever writer +-- happened to remember it. +-- The registry record carries the target its declaration opened. +-- +-- Without it, a consumer asked about a route alias has no way to reach the +-- identity that currently serves it: it would have to join `solution_targets`, +-- which request traffic holds no grant on, or fall back to comparing the alias — +-- which is the defect. A record with NO declaration carries no target and is +-- therefore admissible to nobody, which is the fail-closed answer. +ALTER TABLE public.solution_registrations + ADD COLUMN declared_target_id uuid REFERENCES public.solution_targets(id); + +-- A declaration is whole: binding, generation, release and target arrive +-- together or not at all. The existing three-column form let a target be absent +-- from a record that was otherwise declared, which would read as "declared but +-- installable by nobody" — indistinguishable from the fail-closed answer for an +-- undeclared record, and therefore impossible to diagnose. +-- +-- Existing declarations are completed, or cleared. +-- +-- A record declared before this column existed has a binding, a generation and a +-- release and no target, which the four-column invariant below refuses. The +-- target is recoverable for most of them — a declared record's presence is the +-- LIVE target of the binding that declared it — so those are completed in place. +-- +-- A declaration whose binding has no live target cannot be completed: either the +-- presence was withdrawn, or the target rows predate the binding. Such a record +-- has its declaration CLEARED whole, which makes it undeclared — admissible to +-- nobody, and visible as such — rather than carrying three quarters of a +-- declaration past a constraint that exists to say a declaration is whole. +-- +-- `solution_registrations` is control-plane only (no tenant column, no RLS), so +-- unlike `installations` above this needs no RLS suspension; the migration owner +-- reaches these rows directly. +UPDATE public.solution_registrations AS r + SET declared_target_id = t.id + FROM public.solution_targets AS t + WHERE r.declared_binding_id IS NOT NULL + AND r.declared_target_id IS NULL + AND t.binding_id = r.declared_binding_id + AND t.closed_generation IS NULL; + +UPDATE public.solution_registrations + SET declared_binding_id = NULL, + declared_generation = NULL, + declared_release = NULL, + declared_target_id = NULL + WHERE declared_binding_id IS NOT NULL + AND declared_target_id IS NULL; + +-- Migration 20's three-column version is REPLACED rather than left beside this +-- one. Two overlapping whole-or-absent checks are satisfiable only by their +-- intersection, so keeping both would make the effective invariant something +-- neither constraint states — the kind of schema a reader has to compute. +ALTER TABLE public.solution_registrations + DROP CONSTRAINT solution_registrations_declared_whole; + +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_declared_whole + CHECK ((num_nonnulls(declared_binding_id, declared_generation, declared_release, declared_target_id) = ANY (ARRAY[0, 4]))); + +CREATE TRIGGER installations_target_live_on_update + BEFORE UPDATE ON public.installations + FOR EACH ROW + WHEN (NEW.status = 'active' AND OLD.status <> 'active') + EXECUTE FUNCTION public.installations_target_must_be_live(); diff --git a/module/services/store/migrations/24_solution_delivery_inbox.down.sql b/module/services/store/migrations/24_solution_delivery_inbox.down.sql new file mode 100644 index 000000000..1bcc55007 --- /dev/null +++ b/module/services/store/migrations/24_solution_delivery_inbox.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.solution_delivery_documents; diff --git a/module/services/store/migrations/24_solution_delivery_inbox.up.sql b/module/services/store/migrations/24_solution_delivery_inbox.up.sql new file mode 100644 index 000000000..767446770 --- /dev/null +++ b/module/services/store/migrations/24_solution_delivery_inbox.up.sql @@ -0,0 +1,105 @@ +-- The delivery inbox: every signed document this host has been handed, kept. +-- +-- This replaces a directory as the reconciler's input, and the reason is a +-- defect rather than a preference. A mount is the CURRENT desired set and +-- nothing else, so a document that arrived, was recorded as desired, failed to +-- apply, and then disappeared from the mount was never retried — the host had +-- recorded that delivery wanted something and had no way to want it again. The +-- persisted representation also dropped the carrier, so a restore could not +-- re-verify what it had accepted; it could only trust its own earlier judgement. +-- +-- So the host persists the CARRIER, verbatim, on receipt: the canonical payload +-- and the bundle that attests it. Everything else in this table is derived from +-- those bytes at receipt time and kept so a reader need not re-verify to answer +-- an operator's question. +-- +-- Removal is still a tombstone GENERATION, never the absence of a row. Nothing +-- deletes from this table, which is what makes "delivery stopped talking" and +-- "delivery said remove it" different facts — the distinction a directory +-- cannot express at all. + +CREATE TABLE public.solution_delivery_documents ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + + -- Which half of the lifecycle this document is. The two have different + -- authorised writers and different identities, so they are never + -- interchangeable and the kind is part of every lookup. + kind text NOT NULL, + + -- The document's own identity: a binding id for presence, an authority id + -- for authority. Opaque here on purpose — the host does not parse it, and an + -- earlier authority shape (`:`) was replaced precisely + -- because something had parsed it. + document_id text NOT NULL, + + -- Strictly monotonic per (kind, document_id), starting at 1. + generation bigint NOT NULL, + + -- sha256 of the canonical payload. The idempotency key together with the + -- generation: a re-POST of the same triple is a replay, while the SAME + -- generation with a different hash is a rewrite of a delivered generation + -- and is refused rather than stored. + content_hash text NOT NULL, + + -- The carrier, verbatim. `payload` is the canonical JSON the signature + -- covers; `bundle` is the attestation over exactly those bytes. Both are + -- kept so a restore can re-verify rather than trust the row. + payload bytea NOT NULL, + bundle bytea NOT NULL, + + -- The attested signer identity the verifier returned, and the ownership + -- domain the document asserts. Recorded at receipt so an operator can see + -- who delivered what without re-running verification, and so a later + -- narrowing of the policy is visible as a disagreement with history rather + -- than as a silent change in what the host would accept today. + signer_identity text NOT NULL, + ownership_domain text NOT NULL, + + received_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT solution_delivery_documents_kind_check + CHECK ((kind = ANY (ARRAY['presence'::text, 'authority'::text]))), + CONSTRAINT solution_delivery_documents_document_id_check + CHECK ((document_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,190}[A-Za-z0-9]$'::text)), + CONSTRAINT solution_delivery_documents_generation_check + CHECK ((generation > 0)), + -- `sha256:` plus exactly 64 lowercase hex. A readable stand-in for a digest + -- is not a digest, and this is the constraint that says so. + CONSTRAINT solution_delivery_documents_content_hash_check + CHECK ((content_hash ~ '^sha256:[0-9a-f]{64}$'::text)), + CONSTRAINT solution_delivery_documents_signer_check + CHECK ((length(signer_identity) BETWEEN 1 AND 512)), + CONSTRAINT solution_delivery_documents_domain_check + CHECK ((length(ownership_domain) BETWEEN 1 AND 128)), + -- A carrier with no bytes on either side is not a carrier. Empty would + -- otherwise satisfy every check above and store as a document that can + -- never be re-verified. + CONSTRAINT solution_delivery_documents_payload_present + CHECK ((octet_length(payload) > 0)), + CONSTRAINT solution_delivery_documents_bundle_present + CHECK ((octet_length(bundle) > 0)) +); + +ALTER TABLE ONLY public.solution_delivery_documents + ADD CONSTRAINT solution_delivery_documents_pkey PRIMARY KEY (id); + +-- ONE row per (kind, document_id, generation). This is what makes a rewrite of a +-- delivered generation detectable rather than merely discouraged: a second POST +-- at the same generation with different bytes collides here, and the handler +-- turns that collision into the conflict an operator needs to see instead of +-- overwriting history. +CREATE UNIQUE INDEX solution_delivery_documents_generation_unique + ON public.solution_delivery_documents USING btree (kind, document_id, generation); + +-- The reconciler's read: the newest generation per document, per kind. +CREATE INDEX solution_delivery_documents_newest + ON public.solution_delivery_documents USING btree (kind, document_id, generation DESC); + +CREATE INDEX solution_delivery_documents_received + ON public.solution_delivery_documents USING btree (received_at DESC); + +-- Control-plane only, like every other presence relation: no tenant column, no +-- row-level security, and request traffic has no access. INSERT and SELECT only +-- — no UPDATE and no DELETE, because an inbox that can be edited is not a record +-- of what was received. A correction is a new generation. +GRANT SELECT,INSERT ON TABLE public.solution_delivery_documents TO app_control_plane; diff --git a/module/services/store/migrations/25_policy_log_commits.down.sql b/module/services/store/migrations/25_policy_log_commits.down.sql new file mode 100644 index 000000000..cf28f274e --- /dev/null +++ b/module/services/store/migrations/25_policy_log_commits.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.policy_log_cursor; +DROP TABLE IF EXISTS public.policy_log_commits; diff --git a/module/services/store/migrations/25_policy_log_commits.up.sql b/module/services/store/migrations/25_policy_log_commits.up.sql new file mode 100644 index 000000000..b9dbc4955 --- /dev/null +++ b/module/services/store/migrations/25_policy_log_commits.up.sql @@ -0,0 +1,117 @@ +-- The local half of the policy log protocol: append → receipt → commit. +-- +-- The protocol exists because a transactional audit event plus an append-only +-- table IN THE SAME DATABASE is not an external record of authority. Restoring +-- that database restores revoked authority together with its own history, so the +-- history cannot witness against the state — it was backed up with it. The log +-- is therefore external, and what lives here is only the RECEIPT: proof that an +-- entry reached the log before the narrowing it describes took effect locally. +-- +-- Three rows' worth of state make the protocol work, and each exists because a +-- failure between the two writes has to be recoverable: +-- +-- 1. an APPENDED entry with no commit — the log knows about a narrowing this +-- host has not applied. Every replica must enforce it or refuse to serve +-- until it is reconciled, because the authority of record says the +-- narrowing happened. +-- 2. a COMMITTED entry — the narrowing is applied and the receipt is durable +-- beside it. +-- 3. the CURSOR — how far this host has reconciled the log, so a restart +-- resumes rather than re-reads from the beginning, and so the gap in (1) +-- is computable without scanning everything. +-- +-- The direction of the asymmetry is the whole design: append-ok/commit-fail must +-- fail CLOSED (the narrowing is enforced), while commit-without-append must be +-- impossible (nothing narrows before the log has witnessed it). + +CREATE TABLE public.policy_log_commits ( + -- operation_id is the IDEMPOTENCY KEY, and it is the caller's, not + -- generated here. A retry of the same logical operation — the same + -- revocation, the same tenancy change — must append once and commit once + -- however many times it is attempted, and only the caller knows that two + -- attempts are the same operation. + operation_id text NOT NULL, + + -- The receipt the log returned for this operation. Its presence is what + -- distinguishes "the log witnessed this" from "this host decided it". + receipt text NOT NULL, + + -- The log's own sequence for the appended entry. Monotonic within the log, + -- and what the cursor below is compared against. + log_sequence bigint NOT NULL, + + -- What the operation narrowed, kept so a reconciliation can tell whether a + -- gap has already been closed by other means without re-reading the log + -- entry's payload. + subject_kind text NOT NULL, + subject_id text NOT NULL, + + appended_at timestamp with time zone NOT NULL, + -- NULL while the append has landed and the local narrowing has not. That is + -- the state every replica must treat as "enforce or refuse". + committed_at timestamp with time zone, + + CONSTRAINT policy_log_commits_operation_id_check + CHECK ((length(operation_id) BETWEEN 1 AND 512)), + CONSTRAINT policy_log_commits_receipt_check + CHECK ((length(receipt) BETWEEN 1 AND 1024)), + CONSTRAINT policy_log_commits_sequence_check + CHECK ((log_sequence > 0)), + CONSTRAINT policy_log_commits_subject_kind_check + CHECK ((subject_kind = ANY (ARRAY[ + 'principal'::text, 'binding'::text, 'installation'::text, + 'team_membership'::text, 'scope_grant'::text]))), + CONSTRAINT policy_log_commits_subject_id_check + CHECK ((length(subject_id) BETWEEN 1 AND 512)) +); + +-- ONE row per operation. This is what makes an idempotent retry idempotent: a +-- second attempt collides here rather than appending to the log twice, and a log +-- with two entries for one operation cannot be replayed into a single answer. +ALTER TABLE ONLY public.policy_log_commits + ADD CONSTRAINT policy_log_commits_pkey PRIMARY KEY (operation_id); + +-- The gap query: appended, not committed. Partial, because the uncommitted set +-- is small and is read on every serving check — a full-table index would be +-- read for a predicate that is almost always empty. +CREATE INDEX policy_log_commits_uncommitted + ON public.policy_log_commits USING btree (log_sequence) + WHERE (committed_at IS NULL); + +CREATE INDEX policy_log_commits_subject + ON public.policy_log_commits USING btree (subject_kind, subject_id); + +-- How far this host has reconciled the external log. +-- +-- ONE row, enforced by the primary key on a constant. A cursor that could have +-- two rows would have two answers to "how far have we read", and the safe +-- reading (the lower) would silently re-apply entries while the unsafe one +-- (the higher) would skip them. +CREATE TABLE public.policy_log_cursor ( + id boolean DEFAULT true NOT NULL, + -- The highest log sequence this host has read AND accounted for. Entries + -- above it are unknown to this host; entries at or below it are either + -- committed or present in policy_log_commits as a gap. + reconciled_sequence bigint DEFAULT 0 NOT NULL, + -- When the log was last reached. The serving gate reads this: a host that + -- cannot reach the log does not know whether its authority is current, and + -- the maximum-security answer to that is to stop serving rather than to + -- serve what it last believed. + reached_at timestamp with time zone, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT policy_log_cursor_singleton CHECK ((id = true)), + CONSTRAINT policy_log_cursor_sequence_check CHECK ((reconciled_sequence >= 0)) +); + +ALTER TABLE ONLY public.policy_log_cursor + ADD CONSTRAINT policy_log_cursor_pkey PRIMARY KEY (id); + +INSERT INTO public.policy_log_cursor (id, reconciled_sequence) VALUES (true, 0); + +-- Control-plane only, like every other platform relation. Both are UPDATEable: +-- the commit row is completed by the commit, and the cursor advances. Neither is +-- deletable — a receipt is the evidence that an append happened, and deleting it +-- would make an unreconciled gap disappear rather than be closed. +GRANT SELECT,INSERT,UPDATE ON TABLE public.policy_log_commits TO app_control_plane; +GRANT SELECT,INSERT,UPDATE ON TABLE public.policy_log_cursor TO app_control_plane; diff --git a/module/services/store/migrations/26_solution_registration_cold_cutover.down.sql b/module/services/store/migrations/26_solution_registration_cold_cutover.down.sql new file mode 100644 index 000000000..1cb2cc73b --- /dev/null +++ b/module/services/store/migrations/26_solution_registration_cold_cutover.down.sql @@ -0,0 +1,26 @@ +-- codefly:irreversible the runtime-owned observations are dropped outright and +-- cannot be reconstructed; restore a pre-cutover backup to recover them. +-- +-- The marker above is load-bearing, not decoration. The managed-database +-- qualification rolls the ledger back from the head and must stop somewhere: it +-- reads this marker to find the newest DECLARED-irreversible version, asserts +-- every migration above it reverses cleanly, and halts there naming the +-- frontier. A down file that raises WITHOUT the marker is still a failure, +-- which is what keeps "irreversible" a decision somebody wrote down rather +-- than a rollback that happened to break. +-- +-- Irreversible, but for a narrower reason than before. +-- +-- The up migration no longer destroys rows: a runtime-registered registration +-- is WITHDRAWN and keeps its `runtime_boundary` seed, so identity and the +-- reachability of the runs it admitted both survive. What it does destroy is +-- the runtime-owned OBSERVATIONS — manifest, upstream, service alias, contract +-- versions and the two lease expiry columns, which are dropped outright. +-- Recreating empty columns would not restore those values or their provenance. +-- +-- Restore a pre-cutover backup with its matching application release to recover +-- the observations. Identity does not need recovering: the seeds were never +-- touched. +DO $$ BEGIN + RAISE EXCEPTION 'migration 26 is irreversible: the runtime lease and observation columns are dropped and cannot be recovered (identity is unaffected — runtime_boundary seeds are preserved by the up migration)'; +END $$; diff --git a/module/services/store/migrations/26_solution_registration_cold_cutover.up.sql b/module/services/store/migrations/26_solution_registration_cold_cutover.up.sql new file mode 100644 index 000000000..3d9edd464 --- /dev/null +++ b/module/services/store/migrations/26_solution_registration_cold_cutover.up.sql @@ -0,0 +1,91 @@ +-- Cold cutover: the runtime registration writer is gone, so its OBSERVATIONS +-- are discarded — but a row is never destroyed, and a seed is never regenerated. +-- +-- WHY A WITHDRAWAL RATHER THAN A DELETE. A runtime-registered row with no +-- declared binding is a solution the new registry has not AUTHORIZED. That is a +-- registry STATE, not a row to destroy. Deleting it would take +-- `runtime_boundary` with it, and a later approval through the declared-presence +-- path would mint a fresh `gen_random_uuid()` seed — so every run that solution +-- had already admitted becomes unreachable, because a run is reachable only +-- under the boundary it was admitted with. Withdrawing the row keeps the seed, +-- so approval reactivates the SAME boundary and those runs stay reachable. +-- +-- THE SEED IS NEVER TOUCHED HERE. `runtime_boundary` appears in no statement +-- below. That is the same discipline the save path keeps — the column simply +-- never appears on a write — and it is why re-approval cannot change it. + +-- The seed must exist before anything is withdrawn. Migration 17 added the +-- column NOT NULL with a `gen_random_uuid()` default, so a NULL here is +-- impossible; it is checked anyway because the alternative to surfacing a +-- defect is papering over it, and this one would silently cost a solution its +-- runs. A migration that refuses is recoverable; a regenerated seed is not. +DO $$ +DECLARE unseeded bigint; +BEGIN + SELECT count(*) INTO unseeded + FROM public.solution_registrations + WHERE declared_binding_id IS NULL AND runtime_boundary IS NULL; + IF unseeded > 0 THEN + RAISE EXCEPTION + 'cold cutover refuses: % registration(s) would be withdrawn with no runtime_boundary seed; ' + 'migration 17 makes that impossible, so this is a defect to investigate rather than to migrate past', + unseeded; + END IF; +END $$; + +-- Withdraw every runtime-registered row: it is tombstoned, its observations are +-- cleared, and its seed is left exactly as it is. +UPDATE public.solution_registrations +SET frontend_revision = NULL, frontend_manifest = NULL, + frontend_contract_version = NULL, frontend_lease_expires_at = NULL, + backend_revision = NULL, backend_upstream = NULL, backend_service_alias = NULL, + backend_contract_version = NULL, backend_lease_expires_at = NULL, + tombstoned_at = COALESCE(tombstoned_at, CURRENT_TIMESTAMP), + revision = nextval('public.solution_registry_revision_sequence'), + updated_at = CURRENT_TIMESTAMP +WHERE declared_binding_id IS NULL; + +-- And clear the observations the runtime path wrote on rows that ARE declared. +-- The declared path rewrites them; a runtime's old endpoint or manifest must +-- never read as an observation delivery approved. +UPDATE public.solution_registrations +SET frontend_revision = NULL, frontend_manifest = NULL, + frontend_contract_version = NULL, frontend_lease_expires_at = NULL, + backend_revision = NULL, backend_upstream = NULL, backend_service_alias = NULL, + backend_contract_version = NULL, backend_lease_expires_at = NULL, + revision = nextval('public.solution_registry_revision_sequence'), + updated_at = CURRENT_TIMESTAMP +WHERE declared_binding_id IS NOT NULL + AND (frontend_revision IS NOT NULL OR backend_revision IS NOT NULL); + +-- Dropping the columns also drops any indexes dependent on them. No lease-only +-- table or standalone lease index exists in migrations 1..23. +-- +-- THE DECLARED COLUMNS STAY NULLABLE, and that is a consequence of withdrawing +-- rather than deleting. This migration used to `SET NOT NULL` on all four, +-- which is only possible once every undeclared row is gone — so not setting +-- them is the whole change here. +-- +-- Nothing replaces those NOT NULLs, because the invariant already exists: +-- migration 23 defines `solution_registrations_declared_whole` as +-- `num_nonnulls(...) = ANY (ARRAY[0, 4])`, which is exactly the state machine +-- this needs — wholly declared (authorized) or wholly undeclared (withdrawn, +-- awaiting approval), never three-of-four. An earlier draft of this migration +-- added that constraint a second time and the replay refused it as already +-- existing, which is the right answer: the schema already said it. +ALTER TABLE public.solution_registrations + DROP CONSTRAINT solution_registrations_frontend_half_whole, + DROP CONSTRAINT solution_registrations_backend_half_whole, + DROP COLUMN frontend_lease_expires_at, + DROP COLUMN backend_lease_expires_at, + ADD CONSTRAINT solution_registrations_frontend_half_whole + CHECK (num_nonnulls(frontend_revision, frontend_manifest) IN (0, 2)), + ADD CONSTRAINT solution_registrations_backend_half_whole + CHECK (num_nonnulls(backend_revision, backend_upstream, backend_service_alias) IN (0, 3)); + +-- An undeclared row must also be a withdrawn one: it is not authorized, so it +-- must not read as serving. Enforced rather than left to the writer, because +-- the whole point of the withdrawal above is that the state is durable. +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_undeclared_is_withdrawn + CHECK (declared_binding_id IS NOT NULL OR tombstoned_at IS NOT NULL); diff --git a/module/services/store/migrations/27_policy_log_organization_subject.down.sql b/module/services/store/migrations/27_policy_log_organization_subject.down.sql new file mode 100644 index 000000000..69242e411 --- /dev/null +++ b/module/services/store/migrations/27_policy_log_organization_subject.down.sql @@ -0,0 +1,19 @@ +-- Back to the five kinds. Any row carrying `organization` is deleted first: +-- re-adding the narrower constraint would otherwise fail validation, and a down +-- migration that cannot run is not a down migration. +-- +-- Deleting a commit row here is safe in the one direction that matters. The row +-- records that an append LANDED and the local narrowing committed; removing it +-- makes this host read the operation as unreconciled, which is the fail-closed +-- side — the serving gate then refuses until an operator closes the gap, rather +-- than a narrowing silently reading as applied. +DELETE FROM public.policy_log_commits WHERE subject_kind = 'organization'; + +ALTER TABLE ONLY public.policy_log_commits + DROP CONSTRAINT IF EXISTS policy_log_commits_subject_kind_check; + +ALTER TABLE ONLY public.policy_log_commits + ADD CONSTRAINT policy_log_commits_subject_kind_check + CHECK ((subject_kind = ANY (ARRAY[ + 'principal'::text, 'binding'::text, 'installation'::text, + 'team_membership'::text, 'scope_grant'::text]))); diff --git a/module/services/store/migrations/27_policy_log_organization_subject.up.sql b/module/services/store/migrations/27_policy_log_organization_subject.up.sql new file mode 100644 index 000000000..8de6b52cb --- /dev/null +++ b/module/services/store/migrations/27_policy_log_organization_subject.up.sql @@ -0,0 +1,25 @@ +-- The policy log's subject-kind set gains `organization`, because archiving an +-- organization is a narrowing whose subject is not a grant hanging off one. +-- +-- WHY A NEW MIGRATION RATHER THAN AN EDIT TO 22. The closed set lives in two +-- places — Go's `PolicyLogSubjectKind` and this CHECK — and only the Go half was +-- extended, so every `DeleteOrganization` failed with +-- `policy_log_commits_subject_kind_check` AFTER its append had already landed: +-- the log said a narrowing happened and the local commit refused, which is the +-- unwitnessed-narrowing gap the protocol exists to make visible. Editing 22 in +-- place would not have fixed it on any database that already has 22 applied: the +-- migration runner keys on the VERSION and keeps no content checksum, so an +-- in-place change to an applied migration is silently skipped and the old +-- constraint stays. +-- +-- The new set is a strict SUPERSET of the old, so revalidation of existing rows +-- cannot fail: every row already carries one of the five kinds this keeps. +ALTER TABLE ONLY public.policy_log_commits + DROP CONSTRAINT IF EXISTS policy_log_commits_subject_kind_check; + +ALTER TABLE ONLY public.policy_log_commits + ADD CONSTRAINT policy_log_commits_subject_kind_check + CHECK ((subject_kind = ANY (ARRAY[ + 'principal'::text, 'binding'::text, 'installation'::text, + 'team_membership'::text, 'scope_grant'::text, + 'organization'::text]))); diff --git a/module/services/store/migrations/28_drop_solution_runtime_boundary.down.sql b/module/services/store/migrations/28_drop_solution_runtime_boundary.down.sql new file mode 100644 index 000000000..40c8f3164 --- /dev/null +++ b/module/services/store/migrations/28_drop_solution_runtime_boundary.down.sql @@ -0,0 +1,25 @@ +-- codefly:irreversible the seeds are destroyed outright. Re-adding the column +-- draws FRESH `gen_random_uuid()` values, which is not a restoration: the old +-- seeds were the only input their boundaries had, so a run still executing under +-- a boundary derived from one becomes unreachable from any page. Restore a +-- pre-drop backup with its matching application release to recover them. +-- +-- The marker above is load-bearing, not decoration. The managed-database +-- qualification rolls the ledger back from the head and must stop somewhere: it +-- reads this marker to find the newest DECLARED-irreversible version, asserts +-- every migration above it reverses cleanly, requires the ledger to come to rest +-- exactly on it, and asserts one further step is refused by the frontier's own +-- raise. A down file that raises WITHOUT the marker is still a failure, which is +-- what keeps "irreversible" a decision somebody wrote down rather than a +-- rollback that happened to break. This file moves the frontier from 26 to 28 +-- by the gate's newest-declared-marker rule; migration 26 keeps its own marker, +-- because the frontier is derived and not written down twice. +-- +-- An empty column would be worse than a refusal: with the seeds gone nothing can +-- tell a re-defaulted value from the one a run was filed under, and the mint +-- would seal capabilities under boundaries that name nothing. Code at this +-- version reads `declared_binding_id` instead, so a restored column would also +-- be dead on arrival. +DO $$ BEGIN + RAISE EXCEPTION 'migration 28 is irreversible: the per-registration runtime_boundary seeds are dropped and cannot be recovered (the boundary is derived from the delivered presence binding at this version — restore a pre-drop backup to recover the old seeds)'; +END $$; diff --git a/module/services/store/migrations/28_drop_solution_runtime_boundary.up.sql b/module/services/store/migrations/28_drop_solution_runtime_boundary.up.sql new file mode 100644 index 000000000..61b3a57e7 --- /dev/null +++ b/module/services/store/migrations/28_drop_solution_runtime_boundary.up.sql @@ -0,0 +1,27 @@ +-- Drop the runtime-boundary seed column (issue #952). +-- +-- `runtime_boundary` was a PER-REGISTRATION random (migration +-- `17_solution_runtime_boundary`, `DEFAULT gen_random_uuid()`), and that is the +-- wrong lifetime for a boundary: a solution withdrawn and registered afresh drew +-- a new one and orphaned every run filed under the old, while a binding that +-- never moved could have its seed replaced by a write it did not make. The +-- boundary is now derived from the DELIVERED PRESENCE BINDING — which survives +-- re-registration and is terminal with its tombstone — per organization +-- (`business.SolutionRuntimeBoundary`, a UUIDv5 of the binding id and the org +-- id). Nothing reads this column: the seed read selects `declared_binding_id` +-- and the collision check reads binding ids. +-- +-- The UNIQUE constraint goes with it. It existed to keep two registrations from +-- sharing a seed, so that the mint's refusal of a caller-named task_id was +-- unambiguous about which solution it protected. Binding ids are unique by +-- `solution_targets` identity, so the property it bought is now a property of +-- the thing being hashed. +-- +-- Dropping the column also drops the constraint; both statements are written out +-- so that a reader of the ledger sees the index leave, and `IF EXISTS` keeps the +-- file idempotent against a database where migration 17's own down had run. +ALTER TABLE public.solution_registrations + DROP CONSTRAINT IF EXISTS solution_registrations_runtime_boundary_key; + +ALTER TABLE public.solution_registrations + DROP COLUMN IF EXISTS runtime_boundary; diff --git a/module/services/store/migrations/29_solution_registration_declared_kind.down.sql b/module/services/store/migrations/29_solution_registration_declared_kind.down.sql new file mode 100644 index 000000000..036579027 --- /dev/null +++ b/module/services/store/migrations/29_solution_registration_declared_kind.down.sql @@ -0,0 +1,17 @@ +-- Reversible: the kind is a copy of a field in the applied document, which is +-- stored whole and canonically on solution_host_bindings, so dropping the column +-- discards no fact this host cannot read again. Re-applying recovers it from the +-- next generation of every binding, and the four-column whole-or-absent +-- constraint migration 23 defined is what the registry had before. +ALTER TABLE public.solution_registrations + DROP CONSTRAINT IF EXISTS solution_registrations_declared_kind_check; + +ALTER TABLE public.solution_registrations + DROP CONSTRAINT solution_registrations_declared_whole; + +ALTER TABLE public.solution_registrations + DROP COLUMN declared_kind; + +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_declared_whole + CHECK ((num_nonnulls(declared_binding_id, declared_generation, declared_release, declared_target_id) = ANY (ARRAY[0, 4]))); diff --git a/module/services/store/migrations/29_solution_registration_declared_kind.up.sql b/module/services/store/migrations/29_solution_registration_declared_kind.up.sql new file mode 100644 index 000000000..323ad33bc --- /dev/null +++ b/module/services/store/migrations/29_solution_registration_declared_kind.up.sql @@ -0,0 +1,61 @@ +-- The registry records WHAT a declaration declared the presence of (issue #952). +-- +-- Core requires `kind` on every presence document and refuses any other value +-- (`solutionhost.Kind`: exactly "solution" or "module"), so every admitted +-- declaration has one. The host dropped it on reconcile, and a registry that +-- cannot say which kind a record is cannot route modules at all: a solution is +-- served at /solutions//* behind per-viewer installation admission, a +-- module at /v1//* through the ordinary authenticated pipeline. A host +-- that could not tell them apart had to serve both from one surface — which puts +-- a solution's upstream behind no installation check — or serve neither, which +-- is what this host did. +-- +-- The kind is NOT inferable from anything already stored. `publisher` is +-- attribution, `declared_release` is a release identity, and an alias is a path +-- segment; reading a kind off any of them is guessing at exactly the question +-- that separates a surface with per-viewer admission from one without. +ALTER TABLE public.solution_registrations + ADD COLUMN declared_kind text; + +-- Backfill, and why it is `solution` for every pre-existing declared row. +-- +-- No DEPLOYED database has a declared row: the `declared_*` columns arrive in +-- migration 20 of this same unreleased ledger, so nothing before it ever ran the +-- declaration reconciler. What this covers is a database that applied 20..28 — +-- a developer's, a CI replay's — where a declared row may exist. +-- +-- Such a row is being served at /solutions//* RIGHT NOW, because that is +-- the only surface this host had. Writing `solution` therefore records what the +-- host is doing with the row rather than guessing what the document said, and it +-- is safe in the one direction that matters: it can never promote a record onto +-- the module surface. A module binding on such a database keeps being served at +-- the solution surface until its next generation applies, which rewrites the +-- record from the document and corrects the kind. +-- +-- The alternative — migration 23's "clear the declaration whole" — is wrong +-- here. Clearing makes the row undeclared, which +-- `solution_registrations_undeclared_is_withdrawn` then requires to be +-- tombstoned, and nothing would bring it back: core answers DecisionCurrent for +-- an unchanged generation, so the reconciler would never rewrite the record it +-- had just emptied. +UPDATE public.solution_registrations + SET declared_kind = 'solution' + WHERE declared_binding_id IS NOT NULL + AND declared_kind IS NULL; + +-- Whole-or-absent, extended from four columns to five. Migration 20 defined this +-- as three, migration 23 as four; the declaration is one fact and a row carrying +-- part of it is a row no reader can act on. +ALTER TABLE public.solution_registrations + DROP CONSTRAINT solution_registrations_declared_whole; + +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_declared_whole + CHECK ((num_nonnulls(declared_binding_id, declared_generation, declared_release, declared_target_id, declared_kind) = ANY (ARRAY[0, 5]))); + +-- And the value is one of the two core admits. An enum would be a second place +-- to migrate for every future kind; a CHECK on a text column refuses the same +-- set and names it in the error. +ALTER TABLE public.solution_registrations + ADD CONSTRAINT solution_registrations_declared_kind_check + CHECK ((declared_kind IS NULL) OR (declared_kind = ANY (ARRAY['solution'::text, 'module'::text]))); diff --git a/module/services/store/service.codefly.yaml b/module/services/store/service.codefly.yaml index 32c956f07..62c5cc1f2 100644 --- a/module/services/store/service.codefly.yaml +++ b/module/services/store/service.codefly.yaml @@ -10,8 +10,21 @@ agent: publisher: codefly.dev endpoints: - name: tcp + # No `location: external`. Location says the endpoint lives OUTSIDE the + # system, resolved by DNS rather than allocated an address — and that is + # false here: the render gives this store a ClusterIP Service and a + # cluster-internal address, and the generated topology already carries an + # allocated 5432. `location: external` was inherited from the deprecated + # `visibility: external`, which conflated reach with location; dropping it + # is the correction, measured against the staging render rather than + # reasoned from the word. + # + # NOT VERIFIED BY A BOOT: under core v0.8.1 (the CLI CI runs) this flips + # `Endpoint.External()` from true to false, so how `codefly run` resolves + # the address for accounts locally is asserted from the render, not from a + # graph that came up. Reach is unchanged — private, same module only — on + # v0.8.1, v0.13.0 and v0.14.0, each measured through core's own loader. visibility: private - location: external spec: database-name: users debug: false diff --git a/module/services/telemetry/code/go.mod b/module/services/telemetry/code/go.mod index 263d1c6b5..0ef711b29 100644 --- a/module/services/telemetry/code/go.mod +++ b/module/services/telemetry/code/go.mod @@ -18,7 +18,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 // indirect + github.com/codefly-dev/core v0.9.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/module/services/telemetry/code/go.sum b/module/services/telemetry/code/go.sum index a94236ffd..c77cc861b 100644 --- a/module/services/telemetry/code/go.sum +++ b/module/services/telemetry/code/go.sum @@ -28,8 +28,12 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.7.2-0.20261003173032-735244e805bf h1:fvJJFtvYVDgXMI1BU1s//hqTAWCCouyzuO7A3PD4drA= +github.com/codefly-dev/core v0.7.2-0.20261003173032-735244e805bf/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003222502-9d5f3fb7d5c3 h1:PSDuh+/pGQPLSPJ7DNVnE5dVypL7TkLvsYu1xbM4W6U= +github.com/codefly-dev/core v0.7.2-0.20261003222502-9d5f3fb7d5c3/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.2.1-0.20260926212413-b91ce91eb23e h1:V8YSUvAm0QQgHY9V0QmHiHuvf+beHnI10D6cJLWB2eI= diff --git a/module/services/vault/service.codefly.yaml b/module/services/vault/service.codefly.yaml index 75e87fb52..12b37e37c 100644 --- a/module/services/vault/service.codefly.yaml +++ b/module/services/vault/service.codefly.yaml @@ -12,7 +12,6 @@ secret-service-configurations: endpoints: - name: http visibility: internal - allow-modules: ["*"] spec: transit-key: api-keys deployment: diff --git a/module/tools/composition/behavior_conformance_test.go b/module/tools/composition/behavior_conformance_test.go index bd4c2db35..eaaa3f72a 100644 --- a/module/tools/composition/behavior_conformance_test.go +++ b/module/tools/composition/behavior_conformance_test.go @@ -15,10 +15,9 @@ import ( ) const ( - extAuthzSource = "services/auth-gateway/code/ext_authz.go" - minterSource = "services/accounts/code/pkg/auth/ed25519/minter.go" - hostRuntime = "services/frontend/code/src/solutions/host-runtime.ts" - federationConfig = "configurations/local/federation.env" + extAuthzSource = "services/auth-gateway/code/ext_authz.go" + minterSource = "services/accounts/code/pkg/auth/ed25519/minter.go" + hostRuntime = "services/frontend/code/src/solutions/host-runtime.ts" ) func contractByID(t *testing.T, contracts *updatev0.BehavioralContracts, id string) *structpb.Struct { @@ -147,7 +146,7 @@ func TestGatewayIdentityHeadersMatchTheDeclaredContract(t *testing.T) { } // TestSolutionRuntimeCompatibilityMatchesTheHost holds the federation contract -// to the single place the register route and the Module-Federation host both +// to the single place the read projection and the Module-Federation host both // read, so the declared majors and shared scope cannot drift from the host. func TestSolutionRuntimeCompatibilityMatchesTheHost(t *testing.T) { moduleRoot := findModuleRoot(t) @@ -199,7 +198,7 @@ func TestRegistrationCredentialsMatchTheMinter(t *testing.T) { principal string }{ {"registration.solution.credential", "SolutionRegistrationAudience", "solution:", `"solution:" + solutionID`}, - {"registration.module.rest-federation", "ModuleRegistrationAudience", "module:", `"module:" + prefix`}, + {"registration.module.credential", "ModuleRegistrationAudience", "module:", `"module:" + prefix`}, } { content := contractByID(t, contracts, credential.contract) fields := declaredToken(t, content) @@ -238,97 +237,21 @@ func mintedTTLSeconds(t *testing.T, minter string) float64 { return float64(minutes * 60) } -// TestDeclaredBodyLimitsMatchTheHandlers holds each declared bound to the -// constant that enforces it. These are the numbers most likely to be copied -// from prose and left behind: the token exchange and the registration write -// bound different bodies at different sizes. -func TestDeclaredBodyLimitsMatchTheHandlers(t *testing.T) { +// TestDeclaredPresenceReadSurfacesExist holds the surviving read contract to +// the actual routes and ensures the former frontend write route is absent. +func TestDeclaredPresenceReadSurfacesExist(t *testing.T) { moduleRoot := findModuleRoot(t) - contracts := loadBehavioralContracts(t, moduleRoot) - credential := readSource(t, moduleRoot, "services/auth-gateway/code/gateway_solution_credential.go") - registration := readSource(t, moduleRoot, "services/auth-gateway/code/gateway_solutions.go") - - exchange := at(t, contractByID(t, contracts, "registration.solution.credential"), "exchange", "requestBodyLimitBytes").GetNumberValue() - if enforced := goConstant(t, credential, "solutionRegisterMaxBytes"); exchange != enforced { - t.Errorf("credential exchange declares a %v-byte bound, the handler enforces %v", exchange, enforced) - } - - write := at(t, contractByID(t, contracts, "registration.solution.gateway-upstream"), "requestBodyLimitBytes").GetNumberValue() - if enforced := goConstant(t, registration, "maxSolutionRegistrationBytes"); write != enforced { - t.Errorf("registration write declares a %v-byte bound, the handler enforces %v", write, enforced) - } - // The frontend half states the same gateway bound rather than a bound of - // its own, so it must move with the constant too. - manifest := at(t, contractByID(t, contracts, "registration.solution.frontend-remote"), "manifestBound").GetStringValue() - if !strings.Contains(manifest, fmt.Sprintf("%d", int(goConstant(t, registration, "maxSolutionRegistrationBytes")))) { - t.Errorf("frontend manifest bound %q no longer names the gateway's enforced limit", manifest) - } -} - -// goConstant evaluates a byte-size constant written either as a plain integer -// or as a shifted one (256 << 10). -func goConstant(t *testing.T, source, name string) float64 { - t.Helper() - match := regexp.MustCompile(name + `\s*=\s*(\d+)(?:\s*<<\s*(\d+))?`).FindStringSubmatch(source) - if match == nil { - t.Fatalf("source does not declare %s", name) + contract := contractByID(t, loadBehavioralContracts(t, moduleRoot), "presence.solution.declared-projection") + gateway := readSource(t, moduleRoot, "services/auth-gateway/code/gateway_solutions.go") + if path := at(t, contract, "gatewaySnapshot", "path").GetStringValue(); path != "/solutions/_registry" || !strings.Contains(gateway, `case solutionRegistrySegment:`) { + t.Fatalf("declared registry snapshot %q is not served", path) } - value, err := strconv.Atoi(match[1]) - if err != nil { - t.Fatalf("parse %s: %v", name, err) + if path := at(t, contract, "frontendNavigation", "path").GetStringValue(); path != "/api/solutions" { + t.Fatalf("declared navigation path %q does not match the route", path) } - if match[2] != "" { - shift, err := strconv.Atoi(match[2]) - if err != nil { - t.Fatalf("parse %s shift: %v", name, err) - } - value <<= shift - } - return float64(value) -} - -// TestDeclaredRegistrationSurfacesExist holds every declared route, header and -// configuration key to the source that serves it. A renamed surface is the -// silent break this declaration exists to publish. -func TestDeclaredRegistrationSurfacesExist(t *testing.T) { - moduleRoot := findModuleRoot(t) - gateway := readSource(t, moduleRoot, "services/auth-gateway/code/gateway_solutions.go") + - readSource(t, moduleRoot, "services/auth-gateway/code/gateway_modules.go") + - readSource(t, moduleRoot, "services/auth-gateway/code/gateway_solution_credential.go") - federation := readSource(t, moduleRoot, federationConfig) - - for _, surface := range []struct{ source, literal string }{ - {gateway, `solutionPrefix = "/solutions/"`}, - {gateway, `"_registration-token"`}, - {gateway, `"_register"`}, - {gateway, `"_registry"`}, - {gateway, "/modules/_registration-token"}, - {gateway, "/modules/_register"}, - {gateway, "/modules/_work-context"}, - {gateway, "X-Codefly-Internal-Token"}, - {gateway, "X-Codefly-Solution-Secret"}, - {gateway, "X-Codefly-Solution-Registration"}, - {gateway, "X-Codefly-Module-Secret"}, - {gateway, "X-Codefly-Module-Registration"}, - {federation, "SOLUTION_REGISTRATION_SECRETS"}, - {federation, "MODULE_REGISTRATION_SECRETS"}, - {federation, "MODULE_IDENTITY_SECRETS"}, - } { - if !strings.Contains(surface.source, surface.literal) { - t.Errorf("declared surface %q is no longer served", surface.literal) - } - } - - if _, err := os.Stat(filepath.Join(moduleRoot, "services/frontend/code/src/app/api/solutions/register/route.ts")); err != nil { - t.Errorf("declared frontend registration route is absent: %v", err) - } - // The numbered publisher-binding migration was folded into the baseline, so - // the durable evidence is the invariant it established, not its file name. - baseline := readSource(t, moduleRoot, "services/store/migrations/1_baseline.up.sql") - for _, invariant := range []string{"publisher text NOT NULL", "solution_registrations_publisher_check"} { - if !strings.Contains(baseline, invariant) { - t.Errorf("declared publisher binding is no longer carried by the store baseline: %q absent", invariant) - } + readSource(t, moduleRoot, "services/frontend/code/src/app/api/solutions/route.ts") + if _, err := os.Stat(filepath.Join(moduleRoot, "services/frontend/code/src/app/api/solutions/register/route.ts")); !os.IsNotExist(err) { + t.Fatalf("retired frontend registration route exists or cannot be checked: %v", err) } } diff --git a/module/tools/core_pin_floor_test.go b/module/tools/core_pin_floor_test.go new file mode 100644 index 000000000..8589d5396 --- /dev/null +++ b/module/tools/core_pin_floor_test.go @@ -0,0 +1,90 @@ +package tools + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The core pin must not be "upgraded" into v0.8.x, which is NEWER and contains +// LESS. +// +// Core's main branch cut `v0.8.0` and `v0.8.1` from its own release commits +// while the work this module depends on sat unmerged on a branch. Those tags +// therefore carry **no `workcontext` package and no `solutionhost` static half** +// — the two packages the declared-presence and Work Context work is built on. +// +// Semver says v0.8.1 is newer than the v0.7.2 pseudo-version pinned here, so +// every bulk upgrade path points at it: `go get -u`, `go get core@latest`, and +// this toolchain's own `codefly update workspace` / `update deps`, which are +// already known to move pins past documented ceilings. A reviewer reading +// "latest release is v0.8.1, why is this on a v0.7.2 pseudo-version?" will +// reasonably try to fix it. +// +// The failure that follows is loud but badly misleading: the build reports that +// the module does not contain `solutionhost`, which reads as a broken pin rather +// than a deliberate one. The real target is **v0.9.0**, cut from the merge that +// carries those packages. +// +// This gate is the only thing that holds, because a comment in a go.mod does not +// survive a bulk update verb. +func TestCorePinIsNotAVersionThatDroppedTheWorkThisModuleNeeds(t *testing.T) { + moduleDir := findModuleDir(t) + + // Every Go module in the tree that pins core. Discovered rather than listed: + // a new service pinning core must be covered without anyone remembering to + // add it here, which is how the policy-log service arrived. + var found []string + err := filepath.Walk(moduleDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + // node_modules holds no go.mod worth reading and is enormous. + if info.Name() == "node_modules" || info.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + if info.Name() != "go.mod" { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + pin := regexp.MustCompile(`(?m)^\s*github\.com/codefly-dev/core\s+(\S+)`).FindStringSubmatch(string(data)) + if pin == nil { + return nil + } + relative, _ := filepath.Rel(moduleDir, path) + found = append(found, relative+" "+pin[1]) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", moduleDir, err) + } + if len(found) == 0 { + t.Fatal("no go.mod pins github.com/codefly-dev/core, so this gate measures nothing — " + + "the pin it was written for is in services/accounts/code/go.mod") + } + sort.Strings(found) + + for _, entry := range found { + parts := strings.Fields(entry) + file, version := parts[0], parts[1] + // v0.8.x only. Not a general floor: pinning an OLDER core is a different + // mistake with a different symptom, and a gate that refused every + // version but one would fight the next legitimate bump. + if strings.HasPrefix(version, "v0.8.") { + t.Errorf("%s pins core %s, which is newer than this module's pin and contains LESS: "+ + "v0.8.0 and v0.8.1 were cut from core's main while the workcontext package and the solutionhost "+ + "static half sat unmerged, so neither tag carries them. The build will report that the module does "+ + "not contain solutionhost, which reads as a broken pin rather than a deliberate one. The target is "+ + "v0.9.0, cut from the merge that carries those packages.", file, version) + } + } +} diff --git a/module/tools/execution_binding_wired_test.go b/module/tools/execution_binding_wired_test.go new file mode 100644 index 000000000..1c5b3c741 --- /dev/null +++ b/module/tools/execution_binding_wired_test.go @@ -0,0 +1,224 @@ +package tools + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// A SECURITY MECHANISM NOTHING CALLS IS INDISTINGUISHABLE FROM ONE THAT PASSES, +// and this is the gate on that. +// +// The defect it closes shipped, and it is the one a reviewer is least likely to +// find by reading the mechanism: execution-bound minting — `BindExecution`, the +// three separate digest types, the monotonicity guard on the approved build, +// the TokenReview client — was built, documented, guarded and unit-tested, and +// `SetExecutionBinding` was called from NOWHERE. On a running host the reviewer +// was nil, `BindExecution` answered ErrExecutionUnbound for every caller, and +// the whole mechanism was unreachable. Every unit test passed, because every +// unit test wired the sources itself. +// +// That is why this gate exists at the CALL SITE rather than on the mechanism. +// Reading `module_execution_binding.go` tells you the check is correct; it +// cannot tell you the check runs. Nothing in a code review distinguishes +// "wired" from "wireable", and the deletion that unwires it is a one-line +// change that compiles, passes every test, and silently removes a boundary. +// +// WHY NOT-NIL IS PART OF THE RULE. This repository deliberately wires one +// mechanism with nil arguments — `service.SetPolicyLog(nil, nil)` — and says why +// in a long comment: a host holding a log it can never reach is worse than a +// host holding none. So "the setter is called" is not the property; the property +// is that it is called with real sources. A future edit that reduced +// SetExecutionBinding to `SetExecutionBinding(nil, nil)` would satisfy a grep +// for the call and restore the hole exactly. +func TestExecutionBindingIsWiredInProduction(t *testing.T) { + moduleDir := findModuleDir(t) + work := filepath.Join(moduleDir, "services", "accounts", "code", "work.go") + + fileSet := token.NewFileSet() + parsed, err := parser.ParseFile(fileSet, work, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse work.go: %v", err) + } + + var calls []*ast.CallExpr + ast.Inspect(parsed, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "SetExecutionBinding" { + calls = append(calls, call) + } + return true + }) + + if len(calls) == 0 { + t.Fatal("work.go never calls SetExecutionBinding, so on a running host the execution reviewer and the " + + "approved-build authority are both nil.\n\n" + + "BindExecution then answers ErrExecutionUnbound for every caller and the whole execution-binding " + + "mechanism is unreachable — while every unit test of it still passes, because each wires the " + + "sources itself. That is the state this gate exists to refuse.\n\n" + + "Wire it where both independent sources exist: the Kubernetes client that reviews a delivery " + + "carrier, and the reconciler's ApprovedBuilds().") + } + + for _, call := range calls { + if len(call.Args) != 2 { + t.Fatalf("SetExecutionBinding is called with %d arguments; it takes the reviewer and the authority", + len(call.Args)) + } + for position, argument := range call.Args { + which := []string{"the execution reviewer", "the approved-build authority"}[position] + if identifier, ok := argument.(*ast.Ident); ok && identifier.Name == "nil" { + t.Fatalf("SetExecutionBinding is called with nil for %s at %s.\n\n"+ + "A nil source makes BindExecution answer ErrExecutionUnbound for every caller, which is the "+ + "same observable as not wiring it at all — so this reads as enforcement while enforcing "+ + "nothing.\n\n"+ + "This repository DOES wire SetPolicyLog(nil, nil) deliberately, and explains why in place. "+ + "If there is an equivalent argument for leaving execution binding unwired, it belongs in a "+ + "comment and in this gate, not in a silent nil.", + which, fileSet.Position(argument.Pos())) + } + } + } + + // NON-VACUITY. Everything above would also pass if the call sat in a + // function nothing reaches — a `func wireLater()` nobody calls satisfies the + // AST exactly as the real wiring does, and is the second shape of the same + // defect. So the enclosing function must be one the service's own startup + // path reaches. + enclosing := enclosingFunction(parsed, calls[0].Pos()) + if enclosing == "" { + t.Fatal("SetExecutionBinding is called outside any function in work.go") + } + if enclosing == "init" { + return + } + // The call must be in a function something else in work.go CALLS, so a dead + // helper cannot satisfy this gate. + if !callsFunction(parsed, enclosing) { + t.Fatalf("SetExecutionBinding is wired inside %q, which nothing else in work.go calls.\n\n"+ + "A call in a function the startup path never reaches is the same hole as no call at all, and it "+ + "passes an AST check identically.", enclosing) + } +} + +// callsFunction reports whether any call in the file names `function` from +// OUTSIDE that function's own declaration. +// +// A CallExpr walk, because the substring count this replaced asserted the wrong +// thing. It required `enclosing+"("` to appear at least twice in work.go, which +// is satisfied by a doc comment mentioning `wireLater(`, or by a second +// declaration sharing the name as a prefix — so it checked "the name appears +// twice", not "something calls this", while the gate's entire purpose is the +// second claim. It survived its mutations and held for the tree as it stood, +// which is exactly why it was worth replacing rather than trusting. +// +// A call from inside the function itself does not count: recursion says nothing +// about whether anything reaches it. +func callsFunction(file *ast.File, function string) bool { + var own *ast.FuncDecl + for _, declaration := range file.Decls { + if declared, ok := declaration.(*ast.FuncDecl); ok && declared.Name.Name == function { + own = declared + break + } + } + called := false + ast.Inspect(file, func(node ast.Node) bool { + if called { + return false + } + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + var named string + switch invoked := call.Fun.(type) { + case *ast.Ident: + named = invoked.Name + case *ast.SelectorExpr: + named = invoked.Sel.Name + } + if named != function { + return true + } + if own != nil && own.Pos() <= call.Pos() && call.Pos() <= own.End() { + return true + } + called = true + return false + }) + return called +} + +// TestTheExecutionReviewerAndAuthorityAreDifferentSources is the gate on the +// property that makes the check mean anything. +// +// THE TAUTOLOGY IS THE FAILURE MODE HERE. Filling both sides of "approved == +// running" from one source compares a value to itself: it type-checks, it +// passes for every caller including the superseded pod the check exists to +// refuse, and no test fails. The two arguments must therefore come from +// genuinely different places — the api server for what is running, signed +// documents for what is approved — and a future edit that passed the same +// expression twice, or derived one from the other, would be the defect. +func TestTheExecutionReviewerAndAuthorityAreDifferentSources(t *testing.T) { + moduleDir := findModuleDir(t) + work := filepath.Join(moduleDir, "services", "accounts", "code", "work.go") + + source := readFileOrFail(t, work) + fileSet := token.NewFileSet() + parsed, err := parser.ParseFile(fileSet, work, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse work.go: %v", err) + } + + ast.Inspect(parsed, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "SetExecutionBinding" || len(call.Args) != 2 { + return true + } + reviewer := expressionText(source, fileSet, call.Args[0]) + authority := expressionText(source, fileSet, call.Args[1]) + if reviewer == authority { + t.Fatalf("SetExecutionBinding is given the same expression for both sources (%q).\n\n"+ + "Then 'approved == running' compares a value to itself. It passes for every caller, including "+ + "a pod from a superseded generation, and nothing fails. The approved digest must come from the "+ + "signed authority documents and the running digest from the api server.", reviewer) + } + return true + }) +} + +// enclosingFunction names the function declaration containing a position. +func enclosingFunction(file *ast.File, position token.Pos) string { + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok { + continue + } + if function.Pos() <= position && position <= function.End() { + return function.Name.Name + } + } + return "" +} + +// expressionText returns the source text of an expression, which is what makes +// "the same expression twice" answerable without reimplementing printing. +func expressionText(source string, fileSet *token.FileSet, expression ast.Expr) string { + start := fileSet.Position(expression.Pos()).Offset + end := fileSet.Position(expression.End()).Offset + if start < 0 || end > len(source) || start >= end { + return "" + } + return strings.TrimSpace(source[start:end]) +} diff --git a/module/tools/go.mod b/module/tools/go.mod index d694ed99c..d0f085b43 100644 --- a/module/tools/go.mod +++ b/module/tools/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( github.com/Masterminds/semver/v3 v3.5.0 - github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 + github.com/codefly-dev/core v0.9.0 github.com/yoheimuta/go-protoparser/v4 v4.14.2 golang.org/x/tools v0.49.0 google.golang.org/protobuf v1.36.12 diff --git a/module/tools/go.sum b/module/tools/go.sum index 9a67efddf..6a55b58c7 100644 --- a/module/tools/go.sum +++ b/module/tools/go.sum @@ -28,8 +28,12 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683 h1:/vzymsu5B9NWwdhWRtlU+68VcVEVDmeZNChRCXAUwlE= -github.com/codefly-dev/core v0.5.11-0.20260926231217-d481a2d49683/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= +github.com/codefly-dev/core v0.7.2-0.20261003173032-735244e805bf h1:fvJJFtvYVDgXMI1BU1s//hqTAWCCouyzuO7A3PD4drA= +github.com/codefly-dev/core v0.7.2-0.20261003173032-735244e805bf/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.7.2-0.20261003222502-9d5f3fb7d5c3 h1:PSDuh+/pGQPLSPJ7DNVnE5dVypL7TkLvsYu1xbM4W6U= +github.com/codefly-dev/core v0.7.2-0.20261003222502-9d5f3fb7d5c3/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/module/tools/interface-docs-gate.mjs b/module/tools/interface-docs-gate.mjs index ea3736d53..80e5832b3 100644 --- a/module/tools/interface-docs-gate.mjs +++ b/module/tools/interface-docs-gate.mjs @@ -76,6 +76,7 @@ const SERVICE_CONTEXTS = { PrincipalService: "authorization", ResourceFollowService: "notifications", SSOAdminService: "identity", + SolutionEntitlementService: "authorization", SolutionRegistryService: "platform administration", TeamService: "tenancy", UsageService: "entitlements", diff --git a/module/tools/internal_http_routes_parse_test.go b/module/tools/internal_http_routes_parse_test.go index c303eefde..2278dd098 100644 --- a/module/tools/internal_http_routes_parse_test.go +++ b/module/tools/internal_http_routes_parse_test.go @@ -126,51 +126,6 @@ export async function POST(request: Request): Promise { assertProblem(t, scan, "bound to another name rather than called") } -// A route is internal because it verifies a credential, not because it verifies -// one particular credential. Registration moved from the shared cluster-internal -// token to a signed, solution-bound one; keyed to a single function name, the -// check read that route as no longer internal while the binding still declared -// it and the mesh still denied it. -func TestScanRouteModuleRecognisesEveryCredentialCheck(t *testing.T) { - scan := scanRouteModule("route.ts", ` -import { - consumeRegistrationToken, - SOLUTION_REGISTRATION_HEADER, - verifySolutionRegistration, -} from "@/solutions/registration-authority"; - -async function authorize(request: Request) { - const claims = await verifySolutionRegistration( - request.headers.get(SOLUTION_REGISTRATION_HEADER), - ); - if (!claims) { - return null; - } - return consumeRegistrationToken(claims) ? claims : null; -} - -export async function POST(request: Request): Promise { - if (!(await authorize(request))) { - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - return Response.json({ ok: true }); -} - -export async function DELETE(request: Request): Promise { - if (!(await authorize(request))) { - return Response.json({ error: "unauthorized" }, { status: 401 }); - } - return Response.json({ ok: true }); -} - -export async function GET(): Promise { - return Response.json({ solutions: [] }); -} -`) - assertNoProblems(t, scan) - assertGated(t, scan, "DELETE", "POST") -} - // An import clause may rename the gate. Matching only the exported name read an // aliased gate as absent, and an absent gate is a route nothing requires to be // declared — the fail-open this whole check exists to remove, inside the check. diff --git a/module/tools/internal_http_routes_test.go b/module/tools/internal_http_routes_test.go index d726d1d29..6d67a8db2 100644 --- a/module/tools/internal_http_routes_test.go +++ b/module/tools/internal_http_routes_test.go @@ -26,8 +26,8 @@ import ( // nothing while reading as protection. The correspondence spans a Go-authored // binding and TypeScript route modules, so it is checked at the text level. const ( - moduleManifestFile = "module.codefly.yaml" - internalCallGate = "isTrustedInternalCall" + moduleManifestFile = "module.codefly.yaml" + internalCallGate = "isTrustedInternalCall" // internalRouteExemption is how an author says a gated route is // deliberately not mesh-denied. Absence from internal-http-routes otherwise // means both "not internal" and "forgotten", which is the silence this gate @@ -44,16 +44,9 @@ const ( internalTokenModule = "src/lib/internal-token.ts" ) -// internalRouteGates are the checks that mark a route as carrying authority the -// public front door does not grant, each mapped to the module that defines it. -// There is more than one: registration moved from the shared cluster-internal -// token to a signed, solution-bound credential, and a check keyed to a single -// function name read that route as having stopped being internal — while the -// binding still declared it and the mesh still denied it. What makes a route -// internal is that it verifies a credential, not which credential it verifies. +// internalRouteGates identify the credential checks on cluster-internal routes. var internalRouteGates = map[string]string{ - internalCallGate: internalTokenModule, - "verifySolutionRegistration": "src/solutions/registration-authority.ts", + internalCallGate: internalTokenModule, } // nextRouteHandlerMethods is the set of exported names Next.js serves as route diff --git a/module/tools/migration-reference-gate.mjs b/module/tools/migration-reference-gate.mjs index 43b1df215..42c6d2303 100644 --- a/module/tools/migration-reference-gate.mjs +++ b/module/tools/migration-reference-gate.mjs @@ -83,9 +83,25 @@ function main() { try { const archive = execFileSync('git', ['archive', reference, 'module/services/store/migrations'], { maxBuffer: 32 * 1024 * 1024 }); execFileSync('tar', ['-x', '-C', temp], { input: archive }); - execFileSync('go', ['test', '-count=1', '-v', '-run', '^TestMigrationUpgrade$', '.'], { + // `^TestMigration` rather than the one test by name. This is the only CI + // step that declares `postgres:16` as an external input and has a container + // runtime, so it is where every migration test belongs — and a prefix means + // the next one is covered by existing it rather than by someone remembering + // to widen a regex. A test named by hand here is a test that silently stops + // running the day it is renamed, and `go test` exits 0 when its `-run` + // matches nothing. + // + // MIGRATION_CONSENT_REPLAY is the switch the replay tests read: they start + // their own throwaway cluster and must skip wherever no container runtime + // was declared. MIGRATION_REFERENCE carries the reference ledger that + // TestMigrationUpgrade diffs against; the others ignore it. + execFileSync('go', ['test', '-count=1', '-v', '-timeout', '40m', '-run', '^TestMigration', '.'], { cwd: 'module/services/store/code', stdio: 'inherit', - env: { ...process.env, MIGRATION_REFERENCE: join(temp, 'module/services/store/migrations') }, + env: { + ...process.env, + MIGRATION_REFERENCE: join(temp, 'module/services/store/migrations'), + MIGRATION_CONSENT_REPLAY: '1', + }, }); } finally { rmSync(temp, { recursive: true, force: true }); diff --git a/module/tools/module_capability_enforcement_test.go b/module/tools/module_capability_enforcement_test.go new file mode 100644 index 000000000..5a7a194b2 --- /dev/null +++ b/module/tools/module_capability_enforcement_test.go @@ -0,0 +1,335 @@ +package tools + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// Every module capability path must re-read live authority, and this is the gate +// that makes "every" true rather than aspirational. +// +// Both adversarial reviews of #953 named its absence as the reason condition 1 +// could not be trusted: the enforcement is 25-odd call sites that each have to +// remember to re-read, and the 26th added next month will not. A convention +// enforced by review is a convention that holds until the reviewer is busy. +// +// HOW IT WORKS. `moduleGrant` resolves a module principal's DECLARED ceiling and +// reads nothing live. `AuthorizeModuleCapability` resolves the same ceiling AND +// re-reads the live installation, producer epoch and binding revision. So a +// function that calls `moduleGrant` directly is a capability path that decided +// on the declared ceiling alone — which is exactly what enforcement at use +// replaces. +// +// The gate therefore finds every direct caller of `moduleGrant` outside the +// enforcement file itself, and holds that set against a baseline. +// +// THE BASELINE SHRINKS AND NEVER GROWS, like the repository's boundary gate. A +// new unrouted path fails the build; routing one requires deleting its line. +// That asymmetry is the point — it makes the remaining work visible and finite +// instead of a claim in a commit message, and it cannot be satisfied by adding +// an entry. +func TestEveryModuleCapabilityPathReReadsLiveAuthority(t *testing.T) { + moduleDir := findModuleDir(t) + businessDir := filepath.Join(moduleDir, "services", "accounts", "code", "pkg", "business") + + // Non-vacuity first, and anchored on the ENFORCEMENT rather than on the + // violations. A first version of this gate required at least one direct + // moduleGrant caller to exist — and then every path was routed, zero + // remained, and the gate failed itself. Worse, the inverse would have been + // true too: deleting the wrapper would leave zero callers and the gate would + // have passed. + // + // So what must exist is the wrapper and the thing it wraps. If + // `moduleCapability` stops calling `moduleGrant`, or + // `AuthorizeModuleCapability` disappears, the enforcement is gone however + // few direct callers remain. + requireEnforcementWrapperIntact(t, businessDir) + + // A reviewer found this gate "green about a NAME": it watched calls to + // moduleGrant, and a function reading `declaredModules()` directly skips + // moduleGrant entirely — so it resolved a module principal's declared + // ceiling with no live re-read and the gate said nothing. + // + // That is the difference between gating the enforcement and gating one + // spelling of it. The registry read is the underlying fact; moduleGrant is + // one wrapper over it, and AuthorizeModuleCapability is the one that also + // re-reads. So both are watched now, against the same shrink-only baseline. + callers := directRegistryReaders(t, businessDir) + baseline := unroutedCapabilityPaths(t, moduleDir) + + var added []string + for _, caller := range callers { + if !baseline[caller] { + added = append(added, caller) + } + } + sort.Strings(added) + if len(added) > 0 { + t.Fatalf("these capability paths reach the declared registry directly (moduleGrant or declaredModules) "+ + "and so decide on the DECLARED ceiling alone, "+ + "without re-reading the live installation, producer epoch or binding revision:\n %s\n\n"+ + "Route each through Service.AuthorizeModuleCapability. A path that cannot be routed yet must be added to "+ + "%s WITH a reason — and that file only ever shrinks, so adding to it is a decision a reviewer sees.", + strings.Join(added, "\n "), unroutedCapabilityPathsFile) + } + + // The baseline may not carry paths that no longer exist, or it would hide a + // route that was never taken: a line for a deleted function reads as + // outstanding work forever, and a line for a function that WAS routed makes + // the gate pass while permitting a regression. + present := map[string]bool{} + for _, caller := range callers { + present[caller] = true + } + var stale []string + for path := range baseline { + if !present[path] { + stale = append(stale, path) + } + } + sort.Strings(stale) + if len(stale) > 0 { + t.Fatalf("%s lists capability paths that no longer reach the declared registry directly:\n %s\n\n"+ + "Delete these lines. A baseline that keeps entries for routed or deleted paths stops measuring anything, "+ + "and would let a future regression re-add one of them silently.", + unroutedCapabilityPathsFile, strings.Join(stale, "\n ")) + } +} + +// unroutedCapabilityPathsFile is the shrink-only baseline. +const unroutedCapabilityPathsFile = "services/accounts/code/pkg/business/testdata/unrouted_capability_paths.txt" + +// enforcementFiles are where calling moduleGrant directly is correct: the +// enforcement wrapper itself, and the function's own definition. +var enforcementFiles = map[string]bool{ + "module_capability_enforcement.go": true, + "module_capabilities.go": false, // holds moduleGrant's definition, checked by name below +} + +// directRegistryReaders finds every function that resolves the declared +// registry outside the enforcement wrapper. +// +// It walks the AST rather than grepping, because a grep cannot tell which +// FUNCTION a call sits in — and the function name is what a baseline line has to +// be, or the baseline would be line numbers that churn on every edit. +func directRegistryReaders(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + fileSet := token.NewFileSet() + var callers []string + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + if enforcementFiles[name] { + continue + } + file, err := parser.ParseFile(fileSet, filepath.Join(dir, name), nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name == nil { + continue + } + // moduleGrant itself, and the one wrapper that is SUPPOSED to call + // it, are where a direct call is correct. + // The reads themselves, and the enforcement entry points that are + // SUPPOSED to perform them, are where a direct read is correct. + if registryReads[function.Name.Name] || enforcementEntryPoints[function.Name.Name] { + continue + } + if !readsDeclaredRegistry(function) { + continue + } + callers = append(callers, name+":"+function.Name.Name) + } + } + sort.Strings(callers) + return callers +} + +// requireEnforcementWrapperIntact is the gate's non-vacuity check. +// +// Zero direct callers is the GOAL, so "found nothing" cannot be the failure +// condition. What must hold instead is that the enforcement still exists: +// `moduleCapability` resolves the declared ceiling through `moduleGrant` AND the +// live re-read through `AuthorizeModuleCapability`, which re-reads installation +// freshness, producer epoch and binding revision. +// +// Without this, deleting the wrapper would satisfy the gate perfectly. +func requireEnforcementWrapperIntact(t *testing.T, dir string) { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, filepath.Join(dir, "module_capabilities.go"), nil, 0) + if err != nil { + t.Fatalf("parse module_capabilities.go: %v", err) + } + var wrapper *ast.FuncDecl + definesGrant := false + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name == nil { + continue + } + switch function.Name.Name { + case "moduleCapability": + wrapper = function + case "moduleGrant": + definesGrant = true + } + } + if !definesGrant { + t.Fatal("moduleGrant is gone from module_capabilities.go, so this gate no longer measures anything") + } + if wrapper == nil { + t.Fatal("moduleCapability is gone: the enforcing wrapper every capability path routes through no longer exists, " + + "so zero direct moduleGrant callers would mean nothing") + } + if !callsNamed(wrapper, "AuthorizeModuleCapability") { + t.Fatal("moduleCapability no longer calls AuthorizeModuleCapability, so the capability paths routed through it " + + "have stopped re-reading live authority while still looking routed") + } + requireAdmissionStillReadsLiveAuthority(t, dir) +} + +// requireAdmissionStillReadsLiveAuthority holds the mint-side entry point to the +// reason it is exempt. +// +// It is on this list because it re-reads live authority itself. If it stops, the +// exemption becomes a hole: a function that resolves the declared ceiling and +// nothing live would sit here looking compliant, which is exactly the shape this +// gate exists to refuse. +func requireAdmissionStillReadsLiveAuthority(t *testing.T, dir string) { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, + filepath.Join(dir, "module_installation_admission.go"), nil, 0) + if err != nil { + t.Fatalf("parse module_installation_admission.go: %v", err) + } + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name == nil || function.Name.Name != "AdmitModuleUnderInstallation" { + continue + } + for _, required := range []string{"ResolveModuleInstallation", "liveAuthorityFor"} { + if !callsNamed(function, required) { + t.Fatalf("AdmitModuleUnderInstallation is exempt from this gate because it reads live "+ + "authority, and it no longer calls %s — so the exemption now hides a path that decides "+ + "on the declared ceiling alone", required) + } + } + return + } + t.Fatal("AdmitModuleUnderInstallation is listed as an enforcement entry point but is not declared: " + + "a stale exemption is how a gate goes quietly green") +} + +// callsNamed reports whether a function calls a named function or method. +func callsNamed(function *ast.FuncDecl, name string) bool { + found := false + ast.Inspect(function, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + if fun.Sel != nil && fun.Sel.Name == name { + found = true + return false + } + case *ast.Ident: + if fun.Name == name { + found = true + return false + } + } + return true + }) + return found +} + +// registryReads are the two ways a function can resolve a module principal's +// declared ceiling without re-reading live authority. +// +// `declaredModules` is the registry read itself; `moduleGrant` is a wrapper over +// it that adds a fail-closed unknown-principal check and nothing live. Watching +// only the wrapper is what let four paths bypass this gate. +var registryReads = map[string]bool{ + "moduleGrant": true, + "declaredModules": true, +} + +// enforcementEntryPoints resolve the declared ceiling AND re-read live authority +// in the same function, which is the thing this gate wants rather than the thing +// it forbids. +// +// `moduleCapability` is the wrapper the 25 capability paths route through. +// `AdmitModuleUnderInstallation` is the mint-side entry point: a module +// capability names the installation it acts under, and that function resolves the +// ceiling, checks the solution actually composes this module, and reads the live +// installation and epoch. It appeared here as a violation the moment it was +// written, which is the gate doing its job — the fix is to list the entry point, +// not to widen what counts as compliant. +// +// Each entry is a function a reviewer must look at, so adding one is a decision +// rather than a convenience. The list is checked against the code below: an entry +// naming a function that no longer performs the live re-read fails the gate, +// because a stale exemption is how a gate goes quietly green. +var enforcementEntryPoints = map[string]bool{ + "moduleCapability": true, + "AdmitModuleUnderInstallation": true, +} + +func readsDeclaredRegistry(function *ast.FuncDecl) bool { + found := false + ast.Inspect(function, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if selector, ok := call.Fun.(*ast.SelectorExpr); ok && + selector.Sel != nil && registryReads[selector.Sel.Name] { + found = true + return false + } + if ident, ok := call.Fun.(*ast.Ident); ok && registryReads[ident.Name] { + found = true + return false + } + return true + }) + return found +} + +// unroutedCapabilityPaths reads the baseline. Blank lines and `#` comments are +// skipped, so each entry can carry the reason it is still there. +func unroutedCapabilityPaths(t *testing.T, moduleDir string) map[string]bool { + t.Helper() + data, err := os.ReadFile(filepath.Join(moduleDir, unroutedCapabilityPathsFile)) + if err != nil { + t.Fatalf("read %s: %v", unroutedCapabilityPathsFile, err) + } + paths := map[string]bool{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + paths[line] = true + } + return paths +} diff --git a/module/tools/modulepackage/modulepackage_test.go b/module/tools/modulepackage/modulepackage_test.go index 0a08bfe86..a76274bb0 100644 --- a/module/tools/modulepackage/modulepackage_test.go +++ b/module/tools/modulepackage/modulepackage_test.go @@ -26,9 +26,19 @@ func TestStarterManifestIsTheCoreV2Contract(t *testing.T) { if manifest.ID != "codefly/saas-starter" || manifest.Version != "0.1.0" { t.Fatalf("unexpected package identity: %s@%s", manifest.ID, manifest.Version) } - if len(manifest.Services) != 8 { - t.Fatalf("provided service count = %d, want 8", len(manifest.Services)) - } + // THE SERVICE SET IS DERIVED, NOT COUNTED. This pinned the number 8, and a + // pinned count is the failure AGENTS.md names outright: two branches that + // each add a service each bump 8 to 9, both are "correct" against their own + // tree, and they merge cleanly into a 9 that is silently one short. The + // count also said nothing about WHICH services — a manifest that dropped + // `store` and added something else passed it exactly. + // + // `module/module.codefly.yaml` is the inventory this module composes from, + // so the package manifest must provide exactly those services. Checked in + // BOTH directions: a service in the inventory and not the package is one + // consumers cannot compose, and a service in the package and not the + // inventory is one this module does not build. + requireServicesMatchTheModuleInventory(t, moduleRoot, manifest) for _, contract := range []string{"composition", "frontendPlugin", "settings", "permissions", "fixtures"} { if manifest.Contracts[contract] == "" { t.Errorf("package manifest does not declare Core contract %q", contract) @@ -477,3 +487,42 @@ func findModuleRoot(t *testing.T) string { directory = parent } } + +// requireServicesMatchTheModuleInventory holds the package manifest's provided +// services to the module's own inventory, in both directions. +func requireServicesMatchTheModuleInventory(t *testing.T, moduleRoot string, manifest Manifest) { + t.Helper() + var module struct { + Services []struct { + Name string `yaml:"name"` + } `yaml:"services"` + } + if err := yaml.Unmarshal(mustRead(t, filepath.Join(moduleRoot, "module.codefly.yaml")), &module); err != nil { + t.Fatalf("read the module inventory: %v", err) + } + if len(module.Services) == 0 { + t.Fatal("module.codefly.yaml declares no services, so this check would pass against an empty package manifest") + } + + inventory := map[string]bool{} + for _, service := range module.Services { + inventory[service.Name] = true + } + provided := map[string]bool{} + for _, service := range manifest.Services { + provided[service.Name] = true + } + + for name := range inventory { + if !provided[name] { + t.Errorf("module.codefly.yaml builds %q and the package manifest does not provide it, "+ + "so a consumer composing this module cannot reach it", name) + } + } + for name := range provided { + if !inventory[name] { + t.Errorf("the package manifest provides %q and module.codefly.yaml does not build it, "+ + "so consumers are promised a service this module does not ship", name) + } + } +} diff --git a/module/tools/naming-allowlist.json b/module/tools/naming-allowlist.json index 4c2a92895..2fceed20e 100644 --- a/module/tools/naming-allowlist.json +++ b/module/tools/naming-allowlist.json @@ -16,6 +16,16 @@ "path": ".gitleaks-baseline.json", "reason": "Machine-generated from commit metadata, so author names are reproduced mechanically. Regenerate the baseline rather than hand-editing it; hand-edits are overwritten on the next run.", "ticket": "policy:AGENTS.md" + }, + { + "path": "module/services/accounts/code/pkg/infra/solution_host_bundle_verifier.go", + "reason": "Holds the ONE literal filesystem path the delivery trust anchor is mounted at. It must match byte-for-byte what the platform infrastructure renders and pins, in a different repository, or the host reads a path nothing writes \u2014 which fails closed at boot forever and invites someone to 'fix' it by making the path configurable again, restoring the seam that let a composition choose the anchor its own documents are verified against. A placeholder here would break the file in the strict sense: the constant would name a directory that does not exist. Scoped to this one file deliberately; every other reference in this repository cites the Go constant rather than repeating the literal, so there is one source of truth and nothing to drift.", + "ticket": "policy:AGENTS.md" + }, + { + "path": "module/services/policy-log/code/identity/signing_key.go", + "reason": "Holds the ONE literal path the witness log's Ed25519 signing key is mounted at, by the same rule as the delivery trust anchor: no path argument, no environment override, so the location is out of reach of anything the composition supplies. It must match byte-for-byte what the platform infrastructure renders, in a different repository, or the service reads a path nothing writes and refuses to boot forever \u2014 and the obvious 'fix' is to make the path configurable, which is the seam this design deletes. A placeholder here would name a directory that does not exist. Scoped to this one file: every other reference cites the Go constant rather than repeating the literal.", + "ticket": "policy:AGENTS.md" } ] } diff --git a/module/tools/sealed_authority_claim_test.go b/module/tools/sealed_authority_claim_test.go new file mode 100644 index 000000000..d816ed3fc --- /dev/null +++ b/module/tools/sealed_authority_claim_test.go @@ -0,0 +1,113 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// `accounts/AGENTS.md` described the `min(sealed, live)` model in the PRESENT +// TENSE for as long as none of it existed — sealed ceilings, installation +// revisions, producer epochs, build incarnations, and verification by exact +// binding lookup. An adversarial review caught it, not a gate. +// +// That is a worse failure than an out-of-date document. `AGENTS.md` is the file +// other agents read to decide what is already enforced, so a false present tense +// there does not merely mislead a human: it tells the next agent that a check it +// is about to rely on is in place. +// +// Nothing could have caught it, because a prose claim has no referent. This test +// gives it one, in the only direction that is checkable: while the code still +// performs the lookup the model FORBIDS, the document must carry the marker +// saying the model is not built. The two then move together — the day somebody +// replaces the search with an exact lookup, this test fails and names the +// paragraph to update, instead of the paragraph quietly becoming true-by-accident +// or (as happened) staying false for a release. +// +// It deliberately anchors on a symbol rather than on a count or a line number. +// `operationScopesSubset` IS the forbidden behaviour: searching a principal's +// bindings for one whose scopes happen to contain the presented set, rather than +// resolving the one binding the credential sealed. +func TestSealedAuthorityClaimMatchesWhatTheCodeEnforces(t *testing.T) { + moduleDir := findModuleDir(t) + + source, err := os.ReadFile(filepath.Join(moduleDir, + "services/accounts/code/pkg/business/module_operation_context.go")) + if err != nil { + t.Fatalf("read module_operation_context.go: %v", err) + } + // The scope SEARCH, which the sealed model replaces with an exact binding + // lookup. Its presence is the fact this test reads. + searchesForAScopeSuperset := strings.Contains(string(source), "operationScopesSubset(") + + document, err := os.ReadFile(filepath.Join(moduleDir, "services/accounts/AGENTS.md")) + if err != nil { + t.Fatalf("read accounts/AGENTS.md: %v", err) + } + // Whitespace collapsed: the claim is the sentence, not the column it wraps at. + prose := strings.Join(strings.Fields(string(document)), " ") + + const ( + model = "min(sealed, live)" + // The disclosure the searching call site requires. + // + // This replaced a blunt "NOT BUILT YET" marker over the whole claim. That + // marker was right while nothing was built, and became WRONG when the + // live half landed: it would have forced the document to disclaim an + // enforcement that now exists, and a reader told the whole thing is + // absent stops checking which part is. + // + // So the gate now keys on the specific site. The live re-read is built; + // moduleOperationContextStale still searches; the document must say so in + // those words while it does. + stillSearches = "still searches" + searchSite = "moduleOperationContextStale" + exactOnly = "exact binding lookup" + ) + + if !strings.Contains(prose, model) { + t.Fatalf("accounts/AGENTS.md no longer mentions %q.\n"+ + "It is the authority model the whole module-capability surface is held to; "+ + "deleting the paragraph is not how it stops being wrong.", model) + } + + if searchesForAScopeSuperset { + for _, required := range []string{stillSearches, searchSite} { + if strings.Contains(prose, required) { + continue + } + t.Fatalf("accounts/AGENTS.md describes %q but is missing the phrase %q, which the disclosure about %s needs, "+ + "while module_operation_context.go still calls operationScopesSubset.\n\n"+ + "That call site iterates a principal's bindings and accepts if ANY of them "+ + "contains the presented scopes, so a context minted against a narrow binding "+ + "is satisfied by any wider binding the principal also holds — narrowing one "+ + "achieves nothing while a broader one survives.\n\n"+ + "The live re-read IS built, so a blanket disclaimer over the whole model would "+ + "now be wrong in the other direction. Name this site and what it still does, "+ + "or route it through ExactOperationBinding.", + model, required, searchSite) + } + // The requirement must survive the disclosure, so naming a gap cannot be + // used to quietly drop the rule it defers. + if !strings.Contains(prose, exactOnly) { + t.Fatalf("accounts/AGENTS.md discloses that %s still searches but no longer states the "+ + "requirement that replaces it (%q).\n"+ + "Naming a gap is not the same as dropping the rule: whoever builds this "+ + "has to be able to read what it must do.", searchSite, exactOnly) + } + return + } + + // The search is gone. If the document still says the model is unbuilt, it is + // now wrong in the OTHER direction — understating an enforcement, which makes + // the next agent add a redundant check or distrust a real one. + if strings.Contains(prose, stillSearches) && strings.Contains(prose, searchSite) { + t.Fatalf("module_operation_context.go no longer calls operationScopesSubset, "+ + "but accounts/AGENTS.md still says %q about %s.\n\n"+ + "The forbidden scope search is gone, so the paragraph needs re-reading against "+ + "what is now enforced: if the sealed ceiling and the exact binding lookup are in "+ + "place, state the model in the present tense and delete the gap; if only part of "+ + "it landed, say which part.", model, searchSite) + } +} diff --git a/module/tools/shared_proto_copies_test.go b/module/tools/shared_proto_copies_test.go index c9f88382c..4db977184 100644 --- a/module/tools/shared_proto_copies_test.go +++ b/module/tools/shared_proto_copies_test.go @@ -1,14 +1,14 @@ package tools import ( + "io/fs" "os" "path/filepath" + "sort" + "strings" "testing" ) -// sharedProtoCopies are protobuf files that exist in more than one service's -// proto tree, keyed by the copy and valued by the definition it must match. -// // A service generates only from its own proto directory, and its // buf.gen.local.yaml sets `clean: true` — so a generated file whose proto is not // in that directory is deleted by the next regeneration, and Codefly's @@ -17,30 +17,95 @@ import ( // generated Go without the proto that produces it, and main's "Codefly quality" // gate stayed red until the proto was copied in beside it. // -// The copy is the price of the two services being separate Go modules — +// The copy is the price of the services being separate Go modules — // auth-gateway cannot import accounts/pkg/gen. This test is what keeps the price -// from being paid twice: the wire contract the gateway compiles against must be -// the one accounts publishes, byte for byte, or the two ends of the federation -// handshake can disagree while both compile. -var sharedProtoCopies = map[string]string{ - "services/auth-gateway/proto/saas/accounts/v1/module_registration.proto": "services/accounts/proto/saas/accounts/v1/module_registration.proto", - "services/auth-gateway/proto/saas/accounts/v1/clients.proto": "services/accounts/proto/saas/accounts/v1/clients.proto", -} - +// from being paid twice: the wire contract one service compiles against must be +// the one its peer publishes, byte for byte, or the two ends of a call can +// disagree while both compile. +// +// The set of shared files is DERIVED, not enumerated. It was a hand-written map +// of two paths, and the cost of that was paid in full: `solution_registry.proto` +// was copied into auth-gateway's tree without being added to the map, so when +// the accounts copy reserved field 9 and moved `declared` to 10, the gateway +// kept decoding field 9. Both services compiled, every in-process fake agreed +// with itself because it built the message with the same struct, and +// `GetDeclared()` returned nil on every real response from accounts. A list a +// human must remember to extend is blind to exactly the file nobody remembered. func TestSharedProtoCopiesMatchTheirSource(t *testing.T) { moduleDir := findModuleDir(t) - for copyPath, sourcePath := range sharedProtoCopies { - copyBody, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(copyPath))) + servicesDir := filepath.Join(moduleDir, "services") + + entries, err := os.ReadDir(servicesDir) + if err != nil { + t.Fatalf("read services: %v", err) + } + + // owners maps a proto's path relative to a service's proto tree to every + // service that carries a copy of it. + owners := map[string][]string{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + protoDir := filepath.Join(servicesDir, entry.Name(), "proto") + if info, err := os.Stat(protoDir); err != nil || !info.IsDir() { + continue + } + err := filepath.WalkDir(protoDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".proto") { + return nil + } + rel, err := filepath.Rel(protoDir, path) + if err != nil { + return err + } + key := filepath.ToSlash(rel) + owners[key] = append(owners[key], entry.Name()) + return nil + }) if err != nil { - t.Fatalf("read shared proto copy %s: %v", copyPath, err) + t.Fatalf("walk %s: %v", protoDir, err) + } + } + + shared := 0 + keys := make([]string, 0, len(owners)) + for key := range owners { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + services := owners[key] + if len(services) < 2 { + continue } - sourceBody, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(sourcePath))) + shared++ + sort.Strings(services) + source := services[0] + sourceBody, err := os.ReadFile(filepath.Join(servicesDir, source, "proto", filepath.FromSlash(key))) if err != nil { - t.Fatalf("read shared proto source %s: %v", sourcePath, err) + t.Fatalf("read %s/%s: %v", source, key, err) } - if string(copyBody) != string(sourceBody) { - t.Errorf("%s has diverged from %s; copy the source over it so both services generate the same wire contract", - copyPath, sourcePath) + for _, service := range services[1:] { + copyBody, err := os.ReadFile(filepath.Join(servicesDir, service, "proto", filepath.FromSlash(key))) + if err != nil { + t.Fatalf("read %s/%s: %v", service, key, err) + } + if string(copyBody) != string(sourceBody) { + t.Errorf("services/%s/proto/%s has diverged from services/%s/proto/%s; copy the source over it and regenerate, so both services encode the same wire contract", + service, key, source, key) + } } } + + // A derived set that found nothing would pass silently if the walk broke or + // the layout moved, which is the failure mode the enumerated map had. + if shared == 0 { + t.Fatal("no proto is shared between two services: the walk found nothing to compare, so this gate is not enforcing anything") + } + t.Logf("compared %d shared proto path(s) across service trees", shared) } diff --git a/module/tools/solution_host_env_documentation_test.go b/module/tools/solution_host_env_documentation_test.go new file mode 100644 index 000000000..4350dcd16 --- /dev/null +++ b/module/tools/solution_host_env_documentation_test.go @@ -0,0 +1,92 @@ +package tools + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// Every SOLUTION_HOST_* setting the code reads is documented, and every one the +// documentation names is read. +// +// WHY THIS EXISTS. `SOLUTION_HOST_SIGNER_DOMAINS` was deleted from the code when +// the verifier took over the signer-to-domain mapping, and +// `SOLUTION_REGISTRATION.md` kept a table row describing it as required. In the +// same table, `SOLUTION_HOST_TRUST_POLICY` still offered `local` after the code +// had stopped accepting it. +// +// A configuration table is the ONE place a reader goes to find out what a value +// may be, so a deleted setting left in it is not a stale sentence — it is an +// instruction to configure a host that refuses to boot. The other direction is +// just as bad: a setting the code reads and nothing documents is one an operator +// discovers from a boot failure. +// +// Prose can be wrong in a hundred ways and no test can catch them. The NAMES are +// mechanical, so this catches the half that is. +func TestSolutionHostSettingsAreDocumentedAndRead(t *testing.T) { + moduleDir := findModuleDir(t) + + code := readFileOrFail(t, filepath.Join(moduleDir, "services", "accounts", "code", "work.go")) + document := readFileOrFail(t, filepath.Join(moduleDir, "SOLUTION_REGISTRATION.md")) + + // Read from the code via workspaceEnv, which is the only way a setting + // reaches this service — a name in a comment is not a setting, so the match + // is on the call rather than on the bare name. + readPattern := regexp.MustCompile(`workspaceEnv\([^)]*"(SOLUTION_HOST_[A-Z_]+)"\)`) + read := map[string]bool{} + for _, match := range readPattern.FindAllStringSubmatch(code, -1) { + read[match[1]] = true + } + if len(read) == 0 { + t.Fatal("found no SOLUTION_HOST_* settings read in work.go — this gate cannot pass by finding nothing") + } + + // From the document, only names in a table cell. A name in prose may well be + // discussing a setting that was removed, which is exactly what the fixed + // text now does for SOLUTION_HOST_SIGNER_DOMAINS, and that must stay legal. + documented := map[string]bool{} + cellPattern := regexp.MustCompile("^\\|\\s*`(SOLUTION_HOST_[A-Z_]+)`\\s*\\|") + for _, line := range strings.Split(document, "\n") { + if match := cellPattern.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + documented[match[1]] = true + } + } + + var undocumented, unread []string + for name := range read { + if !documented[name] { + undocumented = append(undocumented, name) + } + } + for name := range documented { + if !read[name] { + unread = append(unread, name) + } + } + sort.Strings(undocumented) + sort.Strings(unread) + + if len(undocumented) > 0 { + t.Errorf("work.go reads these settings and SOLUTION_REGISTRATION.md's tables do not list them:\n %s\n\n"+ + "An operator discovers an undocumented setting from a boot failure. Add a table row.", + strings.Join(undocumented, "\n ")) + } + if len(unread) > 0 { + t.Errorf("SOLUTION_REGISTRATION.md's tables list these settings and work.go reads none of them:\n %s\n\n"+ + "A deleted setting left in a configuration table is an instruction to configure a host that refuses "+ + "to boot. Remove the row — discussing the removal in PROSE is fine and does not trip this gate.", + strings.Join(unread, "\n ")) + } +} + +func readFileOrFail(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} diff --git a/module/tools/solution_registration_boundary_test.go b/module/tools/solution_registration_boundary_test.go index e1c7d108a..4ad08b200 100644 --- a/module/tools/solution_registration_boundary_test.go +++ b/module/tools/solution_registration_boundary_test.go @@ -8,26 +8,132 @@ import ( "testing" ) -// The registration/installation boundary is a DECISION, not an accident: a -// deployment-wide solution registration governs UI and API availability, while a -// per-org installation governs only the solution agent's authority. Uninstalling -// therefore does not take a page or a gateway route away — from that -// organization or any other. -// -// module/SOLUTION_REGISTRATION.md §4 states that limitation. These tests keep -// the statement and the code from drifting apart in either direction: the doc -// must say it, and the registration surfaces must not quietly grow the coupling -// the doc says they do not have. - -// registrationSurfaces are the files that decide whether a solution's UI and API -// are served. None of them may consult installation, entitlement, or tenant -// state — if one ever does, the documented boundary has moved and the doc has to -// move with it. +// The registration/installation boundary is a DECISION, not an accident, and it +// has MOVED TWICE. +// +// What it was: a deployment-wide registration governed UI and API availability, +// while a per-org installation governed only the solution agent's authority, so +// nothing about a tenant affected what any surface served. +// +// #949 moved the PROJECTIONS — the navigation menu and the per-client surface +// listing answer per organization and per viewer, narrowed through installations +// and the viewer's scope grants. It deliberately left route and page exposure +// deployment-wide. +// +// #952 moves the PROXY, because leaving it was a hole rather than a boundary: a +// viewer in any organization could call the data endpoints of every solution the +// deployment ran — with a real bearer forwarded to them — by typing the path the +// menu declined to show. Available, installed and exposed are three layers and +// none is inferred from another, so the component that holds the verified +// identity and forwards the credential is where the second and third are +// enforced for traffic. +// +// What has NOT moved, and why each is deliberate rather than pending: +// +// - The PUBLIC Module-Federation surface (`/solutions/{id}/assets/*` and +// `/.well-known/*`) is fetched by the browser's module loader with no +// credential, so there is no viewer to ask about. It serves the solution's +// own static bytes, which carry no tenant data. +// - The `/s/{id}` PAGE server-renders from the registry. The access token +// lives in this origin's memory, not in a cookie the server can read, so a +// server-side admission check would have to exchange the httpOnly refresh +// cookie — rotating a viewer's refresh token on every page render. The page +// discloses a nav title and a manifest URL the public asset surface already +// serves; every call it makes goes through the gated proxy. +// +// So this file guards two things, and the halves pull in opposite directions. +// The surfaces that must STAY installation-blind are scanned for coupling; the +// surfaces that must now BE installation-aware are asserted to consult it, so +// the narrowing cannot silently regress to the deployment-wide answer it +// replaced. module/SOLUTION_REGISTRATION.md §4 states every half, and the claim +// test below keeps the doc and the code together. + +// registrationSurfaces are the files that answer from the deployment-wide +// registry alone. None of them may consult installation, entitlement, or tenant +// state; if one ever does, that half of the boundary has moved too and the doc +// has to move with it. +// +// registry.ts is here because its findSolution reads the registry and nothing +// per-viewer, which is what the scan was written for. +// +// `/s/[solutionId]/page.tsx` LEFT this list, and that is a correction rather +// than a relaxation. It was listed because this origin could not read a verified +// viewer server-side without rotating a refresh token per render — a TOOLING +// limit, recorded as though it were a boundary. The consequence was that the +// gate FORBADE the page from mentioning installation or entitlement, so the +// only server-side gate on the declared dashboard graph was a `codefly_session` +// cookie the client writes and nothing validates: a viewer-less disclosure, +// reachable by setting that cookie to any value. +// +// A gap in the tooling is a bug in the tooling. The page now gates on the +// gateway-stamped viewer — the same stamp the proxy admits on, verified +// server-side — and so belongs in viewerGatedSurfaces below, which requires the +// coupling this list forbids. +// +// gateway_solutions.go also LEFT this list with #952. It is asserted in +// TestSolutionTrafficIsAdmittedByInstallation below, which requires the +// opposite. var registrationSurfaces = []string{ - "services/frontend/code/src/app/api/solutions/register/route.ts", - "services/frontend/code/src/app/api/solutions/surfaces/route.ts", "services/frontend/code/src/solutions/registry.ts", +} + +// viewerGatedSurfaces are the files that server-render per-viewer content and +// must therefore CONSULT the gateway-stamped viewer. +// +// Separate from projectionSurfaces because the coupling differs: a projection +// narrows a LIST through the viewer's entitlements, while these render one +// solution's declared graph and must refuse a viewer that was never stamped. The +// regression each catches is the same shape — content that looks correct and is +// simply not narrowed — but the identifier that proves it is not. +// +// The stamp is the gateway's, verified server-side, and never a cookie the +// client writes. That distinction is the whole reason this list exists: the +// previous gate was satisfied by a page reading an unvalidated cookie, because +// it only checked that the page did NOT mention entitlement. +var viewerGatedSurfaces = []string{ "services/frontend/code/src/app/(dashboard)/s/[solutionId]/page.tsx", +} + +// viewerGatedSurfacesPending are the viewer-gated surfaces that do NOT yet +// consult the stamp, with the reason each is still here. +// +// THIS LIST ONLY EVER SHRINKS, like the repository's boundary and capability +// gates. A new viewer-gated surface that fails to consult the stamp fails the +// build; clearing one means deleting its line. Adding a line is a decision a +// reviewer sees. +// +// It exists because the gating needs a contract that does not exist yet. There +// is no gateway-stamped viewer header reaching the frontend at all: +// `X-Codefly-Gateway-Token` is stripped from caller input and stamped only for +// accounts routes, with "never expose these capabilities to the frontend +// upstream" written at the call site. So the stamp has to be introduced by the +// gateway and read by the frontend — a cross-service contract, not a page edit, +// and inventing its shape here unilaterally is how two services end up +// disagreeing about what a verified viewer is. +// +// What is NOT pending is the correction: the page has been removed from +// registrationSurfaces, so the gate no longer FORBIDS the gating. That was the +// tooling bug. +var viewerGatedSurfacesPending = map[string]string{ + "services/frontend/code/src/app/(dashboard)/s/[solutionId]/page.tsx": "needs a gateway-stamped viewer header, which no service sends today; " + + "until it exists the only server-side gate is the client-written codefly_session cookie, which is a viewer-less disclosure of the declared dashboard graph", +} + +// projectionSurfaces are the files that decide what a VIEWER is shown. Each must +// consult the per-viewer entitlement read, because a projection that does not is +// the deployment-wide listing #949 removed — and that regression would look +// like working code, since every viewer would simply see everything. +var projectionSurfaces = []string{ + "services/frontend/code/src/app/api/solutions/route.ts", + "services/frontend/code/src/app/api/solutions/surfaces/route.ts", +} + +// trafficSurfaces are the files that decide whether a REQUEST reaches a +// solution. The proxy must consult the per-viewer admission before it forwards +// anything, and the regression it guards against is the one #952 fixed: a proxy +// that routes on registration alone serves every solution to every +// organization, and looks like working code while doing it. +var trafficSurfaces = []string{ "services/auth-gateway/code/gateway_solutions.go", } @@ -103,8 +209,101 @@ func TestSolutionRegistrationBoundaryIsDocumented(t *testing.T) { for _, claim := range []string{ "A registered solution is host-trusted", "governs **agent authority only**", - "does **not** remove the solution's nav entry, page, or gateway route", "Other tenants are unaffected", + // The half that moved with #949. Without these the doc would still read as + // though every caller saw every registered solution. + "the **projections** answer per organization and per viewer", + // The join key, pinned because it is the one the §9 consent-transfer + // attack turned on: an alias is reusable, an identity is not. A change + // that moves this line back to the alias is the regression. + "`installations.target_id` is the target a", + "**never reused**", + "Closing a target revokes every active installation of it, in the same", + "Presence nothing declared is admissible to nobody.", + // The half that moved with #952. The first sentence is the one a reader + // needs in order to know that registration alone no longer reaches a + // solution; the second and third are the two answers that must not be + // collapsed into one, which is the mistake this gate is most likely to + // be "simplified" into. + "the solution proxy admits traffic through the same authority the", + "An outage is not a verdict.", + "Health is not admission.", + // The residual exposure, stated as a decision rather than left to be + // rediscovered as a gap. + "The public Module-Federation surface is not gated, and cannot be.", + "does **not** remove the solution's page for that organization", + // §7b. The host's own control paths, published because three + // repositories independently invented `/platform/_credential` from a + // design note and nothing failed at build time. The paths are pinned + // here so a consumer reading this document is reading a contract: a + // rename that does not move these lines is a rename that breaks every + // consumer silently. + "`POST /platform/_credential`", + "`POST /platform/_delivery/presence`", + "`POST /platform/_delivery/authority`", + // The rule that makes hardcoding the path correct and hardcoding the + // origin wrong. Without it the table reads as an invitation to pin + // everything. + "a path is a contract, an origin is a resolution result", + "`/platform/` is **reserved for the host**", + // The trust model, pinned because each of these is a claim a reader + // would otherwise have to take on faith from a code comment. + // + // The carrier shape and the signing encoding: a consumer that signs the + // YAML this repository's Marshal writes produces a payload the host + // refuses even with a genuine attestation over it, and the document is + // the only place that is stated. + "The signing input is the canonical JSON", + // The delivery carrier contract, pinned because every line of it was + // something one of the three implementing repositories had wrong, and a + // rename that does not move these lines breaks a consumer silently. + // + // The authority pair is the only hardcoded (SA, namespace) in the + // scheme: a wrong constant there makes the TokenReview SUCCEED on a + // genuine identity and the host refuse the real carrier for a name. + "| authority | `delivery` | `platform-authority` — a fixed pair |", + // The presence namespace is NOT a constant, and the reason is the half + // that gets lost first: a fixed value refuses every genuine carrier. + "**the namespace the document's own workloads declare**", + // Offline verification, and the failure shape that makes it matter. + "would **hang rather than fail fast**", + // That the host is the second layer, not the perimeter. Without this + // line the check above reads as the only thing standing in the way. + "The host's SA-and-namespace check is the second", + // The named refusal. The library collapses these two; the distinction + // is the difference between "fix your signer" and "investigate". + "refused BY NAME", + // WHERE the check goes, which is the load-bearing half. The obvious + // implementation — classify the verifier's error — cannot work: the + // verifier emits the same words for "the signer never logged" and "the + // root cannot check the log it did". + "Check it BEFORE the verifier runs", + // And that a signed timestamp is not evidence of logging. This is the + // case a looser "has it got anything?" check admits silently. + "does not stand in for transparency evidence", + // The delivery response taxonomy, pinned because a Job's retry policy is + // written against it. The 401/503 split especially: an earlier draft + // grouped on HTTP class and made a reviewable refusal retryable. + "what a retry would change", + "The review ran and refused", + // That there is ONE policy, and that the second one is gone rather than + // merely discouraged. These two pins replaced "the local policy is + // coordinate-bound" and "keyless is NOT YET AVAILABLE", both of which + // described a host that no longer exists — and the first of which + // documented a gate that was a string comparison against an + // operator-declared value. + "The `local` trust policy is deleted.", + "one verifier, one policy shape, two sets of listed identities", + // That a bundle is required and must be an object. A renderer whose + // pipeline has no signing step yet reaches for the field it can fill — + // `null` — and nothing in the carrier's own shape would have told it + // that produces a document the host treats as unsigned. + "A bundle must be present and be a JSON object", + // That what lands in a row is derived from the attested bytes rather + // than from whatever the pass was holding. This is the claim that makes + // the stored digest meaningful, and it is a property of the dependency, + // so a reader cannot check it by reading this repository alone. + "What the host stores is derived from the attested bytes", } { if !strings.Contains(document, strings.Join(strings.Fields(claim), " ")) { t.Errorf("SOLUTION_REGISTRATION.md no longer states %q", claim) @@ -112,6 +311,118 @@ func TestSolutionRegistrationBoundaryIsDocumented(t *testing.T) { } } +// TestSolutionProjectionsNarrowByInstallation is the inverse of the scan above: +// these files MUST consult the per-viewer entitlement read. +// +// It exists because the regression it catches is invisible. A projection that +// stopped narrowing would not throw, would not fail a type check and would return +// a perfectly well-formed list — just the deployment-wide one, to every viewer. +// Asserting the coupling is present is the only way that shows up as a failure. +func TestSolutionProjectionsNarrowByInstallation(t *testing.T) { + moduleDir := findModuleDir(t) + for _, relative := range projectionSurfaces { + data, err := os.ReadFile(filepath.Join(moduleDir, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + code := codeOnly(string(data)) + // The entitlement read, and the narrowing that consumes it. This is a + // presence check and proves only that: a route that imported both and then + // projected the registered set anyway would still pass here. That behaviour + // is caught by the route tests (a deployed but uninstalled solution must be + // absent from each projection); this test catches the coarser regression of + // a projection route dropping the narrowing entirely. + for _, identifier := range []string{ + "viewerEntitlements", + "entitledSolutions", + } { + if !strings.Contains(code, identifier) { + t.Errorf( + "%s does not reference %q: a solution projection must narrow through installations and the viewer's grants (SOLUTION_REGISTRATION.md §4, issue #949), never answer the deployment-wide set", + relative, identifier, + ) + } + } + } +} + +// TestSolutionTrafficIsAdmittedByInstallation is the #952 half of the inverse +// scan: the solution proxy MUST consult the per-viewer admission. +// +// The regression is invisible for the same reason the projection one is. A proxy +// that dropped the check throws nothing, logs nothing and answers 200 — it just +// serves every registered solution to every organization, which is precisely the +// behaviour that was there before and the reason this hole existed for as long as +// it did. Asserting the coupling is present is what makes its removal a failure. +// +// Presence is all this proves. That an UNINSTALLED solution is actually refused, +// that an authority outage is a 503 rather than a verdict, and that the public +// asset surface stays ungated are proven by the gateway's own tests +// (gateway_solution_admission_test.go), each of which answers 200 against the +// pre-#952 gateway. +func TestSolutionTrafficIsAdmittedByInstallation(t *testing.T) { + moduleDir := findModuleDir(t) + for _, relative := range trafficSurfaces { + data, err := os.ReadFile(filepath.Join(moduleDir, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + code := codeOnly(string(data)) + for _, identifier := range []string{ + // The authority call, and the refusal that is a verdict rather than + // an outage. Both, because a proxy that called the authority and + // then forwarded regardless of the answer would pass on the first + // alone. + "admitViewerSolution", + "viewerSolutionNotEntitled", + "viewerSolutionUndecidable", + } { + if !strings.Contains(code, identifier) { + t.Errorf( + "%s does not reference %q: solution traffic must be admitted through the viewer's installation and grants (SOLUTION_REGISTRATION.md §4, issue #952), never routed on registration alone", + relative, identifier, + ) + } + } + } +} + +// TestSolutionProjectionsDoNotDeriveIdentityLocally keeps the projections off the +// one shortcut that would make the narrowing worthless. +// +// `lib/auth-session.ts` can read an organization out of an access token, but +// `decodeJWTPayload` only base64-decodes it — it verifies nothing. A projection +// that narrowed on that would let any caller read another tenant's menu by editing +// one claim, and it would pass every other test in this file: it consults +// installation state, it narrows, and it is wrong. +func TestSolutionProjectionsDoNotDeriveIdentityLocally(t *testing.T) { + moduleDir := findModuleDir(t) + surfaces := append([]string{ + "services/frontend/code/src/solutions/entitlements.ts", + "services/frontend/code/src/solutions/projections.ts", + "services/frontend/code/src/solutions/registry.ts", + }, projectionSurfaces...) + for _, relative := range surfaces { + data, err := os.ReadFile(filepath.Join(moduleDir, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + code := codeOnly(string(data)) + for _, forbidden := range []string{ + "extractSessionContext", + "decodeJWTPayload", + "extractRoles", + } { + if strings.Contains(code, forbidden) { + t.Errorf( + "%s references %q: it decodes an access token without verifying it, so an organization read from it is one the CALLER chose. The verified tenant and viewer come from the gateway's ext_authz stamp (services/frontend/code/src/solutions/entitlements.ts)", + relative, forbidden, + ) + } + } + } +} + // TestSolutionAndModuleRegistrationCredentialsAreSeparate keeps the two // registrant kinds from collapsing into one grant. A solution remote executes in // the host origin with the viewer's credentials; a module federates a REST @@ -143,3 +454,88 @@ func TestSolutionAndModuleRegistrationCredentialsAreSeparate(t *testing.T) { } } } + +// TestViewerGatedSurfacesConsultTheStampedViewer requires the coupling +// registrationSurfaces forbids, for the files that server-render per-viewer +// content. +// +// `/s/[solutionId]` server-renders `declaredSources` and the whole declared +// dashboard graph. Before this, its only server-side gate was a +// `codefly_session` cookie written client-side and explicitly not validated — so +// the graph was reachable by anyone who set that cookie to any value, with no +// viewer at all. The old gate did not catch that; it REQUIRED it, by forbidding +// the page from mentioning installation or entitlement. +// +// So this asserts the positive: the page must consult the gateway-stamped +// viewer. A cookie the client writes is not a viewer, and the assertion names +// the stamp rather than "some auth" precisely so that swapping it back for a +// cookie fails here. +func TestViewerGatedSurfacesConsultTheStampedViewer(t *testing.T) { + moduleDir := findModuleDir(t) + for _, relative := range viewerGatedSurfaces { + data, err := os.ReadFile(filepath.Join(moduleDir, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + code := codeOnly(string(data)) + // A presence check, and it proves only presence: a page that imported + // the stamp and rendered the declared graph anyway would still pass. The + // behaviour is the page's own test's job. This catches the coarser + // regression — the gate being satisfied while nothing per-viewer is + // consulted at all. + if reason, pending := viewerGatedSurfacesPending[relative]; pending { + // Still required, just not yet satisfiable. Asserted the other way + // round so the entry cannot outlive the gap: once the page consults + // the stamp, this fails until the line is deleted. + if strings.Contains(code, "stampedViewer") { + t.Errorf("%s now consults the stamp, so delete its line from viewerGatedSurfacesPending; "+ + "a pending entry that no longer describes a gap stops measuring anything", relative) + } + t.Logf("PENDING %s: %s", relative, reason) + continue + } + if !strings.Contains(code, "stampedViewer") { + t.Errorf( + "%s does not reference %q: a surface that server-renders a solution's declared graph must gate on the "+ + "gateway-stamped viewer, verified server-side. The `codefly_session` cookie is written by the client "+ + "and its contents are not validated, so gating on it is no gate at all (SOLUTION_REGISTRATION.md §4)", + relative, "stampedViewer", + ) + } + } +} + +// TestTheRegistrationSurfaceScanDoesNotCoverViewerGatedPages is the gate's own +// test, and it exists because this gate was WRONG in a way no test could show. +// +// `registrationSurfaces` forbids installation and entitlement coupling. While +// `/s/[solutionId]/page.tsx` sat on that list, the gate actively prevented the +// page from being gated — a tooling limit ("this origin cannot read a verified +// viewer server-side") recorded as a boundary, and then enforced as one. +// +// The two lists are now mutually exclusive by assertion rather than by anyone +// remembering: a file on both would be required to consult the viewer AND +// forbidden from mentioning entitlement, which is unsatisfiable, so the gate +// would be unpassable rather than quietly wrong. That is the failure mode to +// prefer. +func TestTheRegistrationSurfaceScanDoesNotCoverViewerGatedPages(t *testing.T) { + registry := map[string]bool{} + for _, relative := range registrationSurfaces { + registry[relative] = true + } + for _, relative := range viewerGatedSurfaces { + if registry[relative] { + t.Errorf( + "%s is in BOTH registrationSurfaces and viewerGatedSurfaces, which cannot both hold: one forbids "+ + "installation and entitlement coupling and the other requires consulting the viewer. A file that "+ + "server-renders per-viewer content belongs only in viewerGatedSurfaces — and listing it as a "+ + "registry-only surface is what left the declared dashboard graph behind a client-written cookie.", + relative, + ) + } + } + if len(viewerGatedSurfaces) == 0 { + t.Fatal("viewerGatedSurfaces is empty, so this gate proves nothing; the page it was written for is " + + "services/frontend/code/src/app/(dashboard)/s/[solutionId]/page.tsx") + } +} diff --git a/module/tools/trust_anchor_not_configurable_test.go b/module/tools/trust_anchor_not_configurable_test.go new file mode 100644 index 000000000..f82a0aae4 --- /dev/null +++ b/module/tools/trust_anchor_not_configurable_test.go @@ -0,0 +1,135 @@ +package tools + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The trust anchor's location must be out of the composition's reach BY +// CONSTRUCTION, and this is the gate that keeps it there. +// +// The hole this closes was real and shipped: the keyless verifier read its +// directory from `SOLUTION_HOST_TRUST_MOUNT`, a workspace environment value. +// Workspace environment is delivered by the COMPOSITION, so a composition could +// repoint the verifier at a verification policy and a trust root it wrote +// itself, sign its own presence documents with a key that policy listed, and +// pass every downstream check — because every check was against an anchor the +// composition chose. +// +// "A document never nominates its own anchor" is therefore not a property a +// check can enforce: the thing under attack is where the check's own inputs come +// from. It holds only if the path is not configurable at all. +// +// A code review cannot keep that. Re-adding an override is a one-line change +// that looks like a convenience, reads as a deployability improvement, and +// silently restores the hole — so the rule is mechanical. +func TestTrustAnchorPathIsNotConfigurable(t *testing.T) { + moduleDir := findModuleDir(t) + accountsCode := filepath.Join(moduleDir, "services", "accounts", "code") + + // 1. No environment value may name the anchor. Any workspaceEnv key + // containing TRUST and MOUNT/DIR/PATH/ROOT is the shape of the defect. + work := readFileOrFail(t, filepath.Join(accountsCode, "work.go")) + envPattern := regexp.MustCompile(`workspaceEnv\([^)]*"([A-Z_]*TRUST[A-Z_]*)"\)`) + for _, match := range envPattern.FindAllStringSubmatch(work, -1) { + key := match[1] + for _, suffix := range []string{"MOUNT", "DIR", "PATH", "ROOT", "ANCHOR"} { + if strings.Contains(key, suffix) { + t.Fatalf("work.go reads %q from workspace environment, which the COMPOSITION delivers.\n\n"+ + "That lets a composition point the verifier at a policy and root it wrote itself, so its own "+ + "documents are checked against an anchor it chose — and no downstream check recovers from "+ + "that, because the checks use those inputs.\n\n"+ + "The anchor's location is a constant (infra.SolutionHostTrustAnchorPath). A deployment that "+ + "needs a different path changes the pod spec that mounts it.", key) + } + } + } + + // 2. The constant must exist and be a literal. A const whose value came from + // os.Getenv would satisfy a grep for "const" and none of the property. + verifier := filepath.Join(accountsCode, "pkg", "infra", "solution_host_bundle_verifier.go") + requireLiteralStringConst(t, verifier, "SolutionHostTrustAnchorPath") + + // 3. The constructor must take no path. An optional path is a configurable + // path with extra steps: a caller that CAN pass one is a caller that can + // be given one, and the next edit gives it the env var back. + requireConstructorTakesNoPath(t, verifier, "NewSolutionHostBundleVerifier") +} + +// requireLiteralStringConst checks a named const is declared with a string +// literal rather than a computed value. +func requireLiteralStringConst(t *testing.T, path, name string) { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, declaration := range file.Decls { + group, ok := declaration.(*ast.GenDecl) + if !ok || group.Tok != token.CONST { + continue + } + for _, spec := range group.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, ident := range value.Names { + if ident.Name != name || i >= len(value.Values) { + continue + } + literal, ok := value.Values[i].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + t.Fatalf("%s must be a string LITERAL: a const computed from os.Getenv or a variable "+ + "satisfies \"it is a const\" while restoring exactly the configurability this forbids", name) + } + if !strings.HasPrefix(strings.Trim(literal.Value, `"`), "/") { + t.Fatalf("%s must be an absolute path; a relative one resolves against the working "+ + "directory, which is not a property of the pod spec", name) + } + return + } + } + } + t.Fatalf("%s is not declared in %s — the fixed anchor path is gone, and with it the only thing "+ + "keeping the anchor out of the composition's reach", name, filepath.Base(path)) +} + +// requireConstructorTakesNoPath checks the verifier constructor accepts no +// path-shaped parameter. +func requireConstructorTakesNoPath(t *testing.T, path, name string) { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name == nil || function.Name.Name != name { + continue + } + if function.Type.Params == nil { + return + } + for _, param := range function.Type.Params.List { + for _, ident := range param.Names { + lower := strings.ToLower(ident.Name) + for _, banned := range []string{"mount", "path", "dir", "root", "anchor"} { + if strings.Contains(lower, banned) { + t.Fatalf("%s takes a parameter %q, so the anchor's location is a caller's choice again.\n"+ + "An optional path is a configurable path with extra steps.", name, ident.Name) + } + } + } + } + return + } + t.Fatalf("%s is not declared in %s", name, filepath.Base(path)) +} diff --git a/module/tools/unsealed_credential_disclosure_test.go b/module/tools/unsealed_credential_disclosure_test.go new file mode 100644 index 000000000..46844371a --- /dev/null +++ b/module/tools/unsealed_credential_disclosure_test.go @@ -0,0 +1,109 @@ +package tools + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" +) + +// While an unsealed credential is ACCEPTED, the rules file must say that the +// mechanism enforces nothing. +// +// `checkAgainstSealed` returns on its first line when the seal is nil. Every +// real module credential is unsealed — `StartModuleTask` seals nothing and +// sdk-go's token has no field to seal into — so in a deployment the live read +// runs and its answer is discarded, and the declared grant alone binds. +// +// That is the most dangerous kind of claim to get wrong, because the code looks +// like enforcement: there is a live read, a comparison function, a conjunction of +// terms and tests for each. A reader of `accounts/AGENTS.md` deciding whether +// uninstalling a solution revokes a module capability in flight gets the answer +// from that file, and the answer is no. +// +// This gate has already caught the same paragraph claiming too much three times, +// in three different wordings, so it keys on the CONSEQUENCES rather than on a +// phrase: whichever way the prose is rewritten, it must still say that +// uninstalling does not revoke. When the seal is populated and the nil branch +// stops being permissive, this gate inverts — see the second half. +func TestUnsealedCredentialDisclosureMatchesTheCode(t *testing.T) { + moduleDir := findModuleDir(t) + enforcement := filepath.Join(moduleDir, "services", "accounts", "code", + "pkg", "business", "module_capability_enforcement.go") + + permissive := nilSealReturnsNoError(t, enforcement) + + document := readFileOrFail(t, filepath.Join(moduleDir, "services", "accounts", "AGENTS.md")) + prose := strings.Join(strings.Fields(document), " ") + + const ( + enforcesNothing = "enforces NOTHING on a module capability" + uninstallIsNoop = "Uninstalling a solution does not revoke a module capability in flight" + ) + + if permissive { + for _, required := range []string{enforcesNothing, uninstallIsNoop} { + if strings.Contains(prose, required) { + continue + } + t.Fatalf("checkAgainstSealed accepts an unsealed credential, so every real module capability is "+ + "decided on the declared grant alone — but accounts/AGENTS.md does not say %q.\n\n"+ + "The code LOOKS like enforcement: a live read, a comparison, a conjunction of terms, a test per "+ + "term. Someone deciding whether an uninstall revokes a capability in flight reads that file, and "+ + "the answer is no. Say so, or populate the seal.", required) + } + return + } + + // The nil branch is no longer permissive. The disclosure is now WRONG in the + // other direction — understating an enforcement that holds makes the next + // reader add a redundant check or distrust a real one. + if strings.Contains(prose, enforcesNothing) { + t.Fatalf("checkAgainstSealed no longer accepts an unsealed credential, but accounts/AGENTS.md still says "+ + "%q.\n\nRe-read that paragraph against the code: understating an enforcement is its own defect.", + enforcesNothing) + } +} + +// nilSealReturnsNoError reports whether checkAgainstSealed's first statement is +// a nil check that returns a nil error. +// +// Checked structurally rather than by grepping for "sealed == nil", because the +// property is "an unsealed credential is accepted" and a rewrite that keeps the +// behaviour while changing the spelling must not slip past. +func nilSealReturnsNoError(t *testing.T, path string) bool { + t.Helper() + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name == nil || function.Name.Name != "checkAgainstSealed" { + continue + } + if function.Body == nil || len(function.Body.List) == 0 { + t.Fatal("checkAgainstSealed has no body, so this gate cannot judge what an unsealed credential gets") + } + branch, ok := function.Body.List[0].(*ast.IfStmt) + if !ok { + return false + } + for _, statement := range branch.Body.List { + ret, ok := statement.(*ast.ReturnStmt) + if !ok || len(ret.Results) != 1 { + continue + } + if ident, ok := ret.Results[0].(*ast.Ident); ok && ident.Name == "nil" { + return true + } + } + return false + } + t.Fatal("checkAgainstSealed is not declared: the sealed half of min(sealed, live) is gone, " + + "and with it the only thing this gate measures") + return false +} diff --git a/qualification/managed-database/run.py b/qualification/managed-database/run.py index e14bdfbf0..b56e3a997 100644 --- a/qualification/managed-database/run.py +++ b/qualification/managed-database/run.py @@ -309,21 +309,56 @@ def main(): if args.package: run(['docker','cp',str(staged),fresh+':/tmp/stage']) run(['docker','cp',str(args.migrate),fresh+':/tmp/migrate']) - above=sum(1 for f in ledger.glob('*.up.sql') if int(f.name.split('_')[0])>1) + # The rollback stops at the newest DECLARED-irreversible migration + # rather than pretending the whole ledger reverses. A cold cutover + # destroys runtime-owned state by design, so its down file raises — + # and the gate must neither fail on that nor be loosened to tolerate + # any raise. It reads the `codefly:irreversible` marker, so an + # irreversible migration is one somebody DECLARED; a down file that + # raises without the marker breaks the rollback below and fails, + # which is the property worth keeping. + irreversible=[int(f.name.split('_')[0]) for f in ledger.glob('*.down.sql') + if 'codefly:irreversible' in f.read_text()] + frontier=max(irreversible) if irreversible else 1 + versions=sorted(int(f.name.split('_')[0]) for f in ledger.glob('*.up.sql')) + above=sum(1 for v in versions if v>frontier) if above: + # Every migration above the frontier reverses cleanly, one + # command, and the ledger must come to rest exactly on it. run(migration_command(fresh,'example_migrator','down',str(above))) - assert sql(fresh,"SELECT version::text||':'||dirty::text FROM schema_migrations").stdout.strip()=='1:false' - policy_before=sql(fresh,'SELECT count(*) FROM pg_policy').stdout - r=run(migration_command(fresh,'example_migrator','down','1'),check=False) - assert r.returncode and 'forward-only' in r.stderr,r.stderr - assert sql(fresh,'SELECT count(*) FROM pg_policy').stdout==policy_before - # A down of the first version targets "no version": golang-migrate marks - # that target dirty before running the file, so the refusal leaves the - # ledger at -1:true with the schema intact. `migrate force 1` recovers it; - # nothing is dropped. - ledger_state=sql(fresh,"SELECT coalesce(string_agg(version::text||':'||dirty::text,','),'') FROM schema_migrations").stdout.strip() - assert ledger_state=='-1:true',ledger_state - tests.append({'case':'baseline_down_is_refused_before_any_removal_and_leaves_dirty_ledger','passed':True}) + resting=sql(fresh,"SELECT version::text||':'||dirty::text FROM schema_migrations").stdout.strip() + assert resting==f'{frontier}:false',(resting,frontier) + tests.append({'case':'rollback_reverses_every_migration_above_the_declared_irreversible_frontier', + 'passed':True,'irreversible_frontier':frontier,'reversed':above}) + if frontier>1: + # It stops THERE: stepping once more must be refused by the + # frontier's own raise, and must leave the schema alone. + policy_at_frontier=sql(fresh,'SELECT count(*) FROM pg_policy').stdout + r=run(migration_command(fresh,'example_migrator','down','1'),check=False) + assert r.returncode and 'irreversible' in r.stderr,r.stderr + assert sql(fresh,'SELECT count(*) FROM pg_policy').stdout==policy_at_frontier + tests.append({'case':'the_declared_irreversible_frontier_refuses_its_own_down_and_drops_nothing', + 'passed':True,'frontier':frontier}) + print(json.dumps({'irreversible_frontier':frontier, + 'reversed_above_frontier':above, + 'baseline_down_not_reached':True})) + # The baseline's own forward-only refusal is only REACHABLE when no + # declared-irreversible migration sits above it. With a frontier the + # rollback stops higher up by design, so asserting 'forward-only' + # here would assert the wrong refusal — the frontier's raise is what + # the ledger meets, and the block above already holds that. + if frontier==1: + policy_before=sql(fresh,'SELECT count(*) FROM pg_policy').stdout + r=run(migration_command(fresh,'example_migrator','down','1'),check=False) + assert r.returncode and 'forward-only' in r.stderr,r.stderr + assert sql(fresh,'SELECT count(*) FROM pg_policy').stdout==policy_before + # A down of the first version targets "no version": golang-migrate marks + # that target dirty before running the file, so the refusal leaves the + # ledger at -1:true with the schema intact. `migrate force 1` recovers it; + # nothing is dropped. + ledger_state=sql(fresh,"SELECT coalesce(string_agg(version::text||':'||dirty::text,','),'') FROM schema_migrations").stdout.strip() + assert ledger_state=='-1:true',ledger_state + tests.append({'case':'baseline_down_is_refused_before_any_removal_and_leaves_dirty_ledger','passed':True}) migration_safety(args.migrate,tests,containers) receipt={'passed':True,'source':run(['git','rev-parse','HEAD'],cwd=ROOT).stdout.strip(),'dirty':bool(run(['git','status','--porcelain'],cwd=ROOT).stdout),'postgres_image':IMAGE,'server_version':sql(canonical,'SHOW server_version').stdout.strip(),'ledger_head':head,'tests':tests,'bootstrap':bootstrap_receipts} args.output.parent.mkdir(parents=True,exist_ok=True);args.output.write_text(json.dumps(receipt,indent=2)+'\n') diff --git a/scripts/ci/ci-performance.test.mjs b/scripts/ci/ci-performance.test.mjs index 0e67c9779..9eb71b628 100644 --- a/scripts/ci/ci-performance.test.mjs +++ b/scripts/ci/ci-performance.test.mjs @@ -60,7 +60,12 @@ test('the CLI installer retries resets, verifies downloads, and fails after exha } }); -const DEFAULT_CODEFLY_VERSION = '0.1.171'; +// Declared here as well as in `scripts/ci/install-codefly.sh` on purpose: the +// duplicate is the friction that makes moving the pin a deliberate edit in both +// places, and it is what caught this branch moving the installer to 0.1.179 +// while this constant still said 0.1.171. Deriving it from the installer would +// make the second assertion below tautological and lose that. +const DEFAULT_CODEFLY_VERSION = '0.1.179'; // The planner's selection and every phase's execution must come from one // CLI: the plan-only 0.1.151 override (#743) existed while the published diff --git a/scripts/ci/install-codefly.sh b/scripts/ci/install-codefly.sh index bcf13d0e6..ab4ff8125 100644 --- a/scripts/ci/install-codefly.sh +++ b/scripts/ci/install-codefly.sh @@ -2,7 +2,7 @@ # The hosted CI runner is Linux x64. Keep the release and archive digest paired. set -euo pipefail -version="${CODEFLY_VERSION:-0.1.171}" +version="${CODEFLY_VERSION:-0.1.179}" case "${version}" in 0.1.145) checksum=a6e1a0e7f4adae8b2701dcea7e05cb03f1ac49c85ee96b4ac98dd2fa20dcc4c7 @@ -53,6 +53,22 @@ case "${version}" in # codefly.dev/docker-build-recipe/v4 and configuration-value-template/v1. checksum=0bdb109784b9835bea984a94ddb9a809c8c29f0e260c59f821a637cbff464ffe ;; + 0.1.179) + # The pin the committed generated bytes are actually produced by, which is + # the point of moving it: `generate proto`, `generate contracts` and the + # saas-sdk client all stamp their generator, and until now CI reproduced + # none of them — main's own library.codefly.yaml says 0.1.178 against a + # 0.1.171 pin, so the inconsistency predates this branch and sync-drift was + # red on a diff nobody could explain. + # + # Nothing else moves with it. Core's build-recipe support is unchanged from + # 0.1.171 — it still tops out at codefly.dev/docker-build-recipe/v4, read + # off the v0.1.179 release's own agent-requirements — so no service-agent + # pin changes, and the one functional difference (cli#911, the workspace + # resolver registered where every command's context is born) only helps a + # solution composed against this host resolve. + checksum=6e7666ef095815a77c10fb1644c69e17b0e8869476c18713bfaa496971b5c45f + ;; *) echo "Unsupported Codefly CI version: ${version}" >&2 exit 1 diff --git a/topology_manifests.go b/topology_manifests.go index e0f91c7d5..797cbc150 100644 --- a/topology_manifests.go +++ b/topology_manifests.go @@ -234,6 +234,25 @@ func endpointAPI(name, api string) string { // endpointVisibility projects the authored Codefly axes into the host's // deployment catalog categories. Its MODULE category means all composed // modules, so a narrower allow-list cannot be represented and must refuse. +// +// THIS IS THE SECOND COPY OF THIS PROJECTION. The shipped generator holds the +// other, as manifestCatalogVisibility in +// module/services/accounts/code/pkg/cataloggen/deployment_model.go, and the two +// are in separate Go modules so neither can import the other. They must agree, +// and when they did not the symptom was a CI red nobody expected: this copy still +// required `allow-modules: ["*"]` after the manifests stopped authoring it, so +// TestShippedModuleAuthorityPortIsAllocatedNotDeclared failed on a module +// manifest the other copy accepted. TestEndpointCatalogProjectionPreservesOrRefusesPolicy +// and the accounts-side table are the pin; a change here needs the same change +// there. +// +// `internal` with NO allow-list is the v0.14.0 spelling of what `internal` plus +// the wildcard said, and maps to the same category: core refuses that key on an +// export by name, so the two spellings mean one thing and which modules reach the +// endpoint is decided by their own declared dependencies either way. The wildcard +// stays accepted for older heads of this repository; a NARROWER list still +// refuses, because this catalog cannot express "some modules" and admitting it +// would silently widen the generated policy to every module. func endpointVisibility(visibility, location string, allowed []string) (string, error) { if visibility == "" { visibility = "private" @@ -251,8 +270,8 @@ func endpointVisibility(visibility, location string, allowed []string) (string, } return visibility, nil case "internal": - if len(allowed) != 1 || allowed[0] != "*" { - return "", fmt.Errorf("deployment catalog requires internal visibility with allow-modules [*]; narrower exports need an allow-list-aware catalog") + if len(allowed) != 0 && (len(allowed) != 1 || allowed[0] != "*") { + return "", fmt.Errorf("deployment catalog accepts internal visibility with no allow-modules, or the legacy [*]; a narrower export needs an allow-list-aware catalog") } return "module", nil default: diff --git a/topology_visibility_test.go b/topology_visibility_test.go index 6c8c88e83..e299d9e8b 100644 --- a/topology_visibility_test.go +++ b/topology_visibility_test.go @@ -2,18 +2,33 @@ package main import "testing" +// This table is one half of a PIN. The shipped generator holds the same +// projection as manifestCatalogVisibility +// (module/services/accounts/code/pkg/cataloggen/deployment_model.go) in a separate +// Go module, so neither side can import the other and only matching tables keep +// them honest. When they drifted — this copy still demanding +// `allow-modules: ["*"]` after the manifests stopped authoring it — +// TestShippedModuleAuthorityPortIsAllocatedNotDeclared went red on a manifest the +// other copy accepted. A row added here is owed to the accounts table too. func TestEndpointCatalogProjectionPreservesOrRefusesPolicy(t *testing.T) { for _, tc := range []struct { visibility, location string allowed []string want string }{ + // `internal` with NO allow-list is what the manifests author at core + // v0.14.0, and it maps to the same category the legacy wildcard did. + {"internal", "", nil, "module"}, + {"internal", "", []string{}, "module"}, {"internal", "", []string{"*"}, "module"}, {"private", "external", nil, "external"}, {"public", "", nil, "public"}, {"", "", nil, "private"}, + // A NARROWER list still refuses: this catalog cannot express "some + // modules", so admitting one would silently widen the generated policy to + // every module. {"internal", "", []string{"example"}, ""}, - {"internal", "", nil, ""}, + {"internal", "", []string{"*", "example"}, ""}, {"public", "", []string{"example"}, ""}, {"module", "", nil, ""}, {"external", "", nil, ""},