From 6bc7775ec28e6cbf34e6547c7234e1c89c991b25 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:23:30 -0400 Subject: [PATCH 1/4] feat: solution projections answer per organization and viewer, not deployment-wide (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A viewer now sees the solutions their organization installed and their teams were granted. The navigation menu and the per-client surface listing used to answer the same deployment-wide set to every caller, so both were a function of what is registered rather than of what the viewer may use. The identity is never derived locally. `/api/*` route handlers are not proxied through the gateway, so nothing stamps a verified identity on them and `decodeJWTPayload` verifies nothing — an organization read from a claim there is one the caller chose. The projections forward the caller's credential to a new `GET /solutions/_entitlements` on the gateway, which runs ext_authz, reads the organization and viewer off its own check response, and asks accounts. accounts gains two internal-tier reads: `InstallationService.ListInstallations`, the org-scoped paginated listing that did not exist, and `SolutionEntitlementService.ListSolutionEntitlements`, which resolves the installation set and the scope grant + share union in ONE org transaction through the same `accessibleScopesQuery` CheckAccess resolves — so a projection can never offer what an authority check would deny, and a revoke cannot land between two reads. The projection cache is keyed on organization, viewer, a digest of the entitlement answer, and client kind, so a grant or a revoke moves the key and a cached menu cannot outlive the grant that justified it. The authority is asked on every request. Registration stays deployment-wide: `/s/{id}` renders and `/solutions/{id}/…` proxies for any authenticated caller, whatever any organization installed. The boundary test is narrowed rather than weakened — the proxy and page keep their installation-blind scan, the projections gain the opposite assertion plus one forbidding the unverified local decoders — and SOLUTION_REGISTRATION.md §4 moves with it. Co-Authored-By: Claude Opus 5 (1M context) --- module/DEPLOYMENT_TOPOLOGY.md | 3 +- module/SOLUTION_REGISTRATION.md | 55 ++- .../saas/accounts/v1/installations.proto | 58 +++ .../accounts/v1/solution_entitlements.proto | 90 ++++ .../v1/solution_entitlements_service.proto | 37 ++ .../saas/accounts/v1/installations.proto | 58 +++ .../accounts/v1/solution_entitlements.proto | 90 ++++ .../v1/solution_entitlements_service.proto | 37 ++ .../api/auth-gateway/grpc/contract.binpb | Bin 337124 -> 342073 bytes .../accounts/v1/solution_entitlements.proto | 90 ++++ module/services/accounts/AUTHZ_MATRIX.md | 6 +- .../code/pkg/adapters/connect_bindings.yaml | 5 + .../connect_handlers_installations.go | 4 + .../connect_registration_catalog_gen.go | 72 ++-- .../adapters/grpc_registration_catalog_gen.go | 2 + .../code/pkg/adapters/installation_rpcs.go | 18 + .../code/pkg/adapters/permission_servers.go | 4 + .../pkg/adapters/solution_entitlement_rpcs.go | 50 +++ .../code/pkg/business/installations.go | 48 +++ .../code/pkg/business/introspection.go | 2 + .../code/pkg/business/layered_access.go | 17 + .../pkg/business/rpc_policy_inventory_test.go | 2 + .../code/pkg/business/service_catalog_test.go | 4 +- .../pkg/business/solution_entitlements.go | 73 ++++ .../code/pkg/cataloggen/authz_methods_test.go | 2 +- .../testdata/mesh-policy.golden.yaml | 2 + .../installations.connect.go | 45 ++ .../solution_entitlements_service.connect.go | 121 ++++++ .../gen/saas/accounts/v1/installations.pb.go | 261 +++++++++-- .../saas/accounts/v1/installations_grpc.pb.go | 54 +++ .../accounts/v1/solution_entitlements.pb.go | 314 ++++++++++++++ .../v1/solution_entitlements_service.pb.go | 70 +++ .../solution_entitlements_service_grpc.pb.go | 143 +++++++ .../code/pkg/infra/postgres_installations.go | 87 ++++ .../infra/postgres_solution_entitlements.go | 120 ++++++ .../postgres_solution_entitlements_test.go | 405 ++++++++++++++++++ .../accounts/generated/authz-methods.json | 74 ++++ .../accounts/generated/service-catalog.json | 94 ++++ .../saas/accounts/v1/installations.proto | 58 +++ .../accounts/v1/solution_entitlements.proto | 90 ++++ .../v1/solution_entitlements_service.proto | 37 ++ .../auth-gateway/code/authz_catalog_gen.go | 2 + module/services/auth-gateway/code/gateway.go | 5 + .../code/gateway_solution_entitlements.go | 236 ++++++++++ .../gateway_solution_entitlements_test.go | 238 ++++++++++ .../auth-gateway/code/gateway_solutions.go | 10 + module/services/auth-gateway/code/main.go | 4 + .../code/perimeter_workos_free_test.go | 5 + .../accounts/v1/solution_entitlements.pb.go | 314 ++++++++++++++ .../accounts/v1/solution_entitlements.proto | 90 ++++ module/services/frontend/AGENTS.md | 41 +- .../gen/saas/accounts/v1/installations_pb.ts | 117 ++++- .../accounts/v1/solution_entitlements_pb.ts | 143 +++++++ .../v1/solution_entitlements_service_pb.ts | 41 ++ .../solutions/__tests__/route.test.ts | 23 +- .../register/__tests__/route.test.ts | 54 ++- .../src/app/api/solutions/register/route.ts | 58 ++- .../surfaces/__tests__/route.test.ts | 225 +++++++++- .../src/app/api/solutions/surfaces/route.ts | 72 +++- .../gen/saas/accounts/v1/frontend_catalog.ts | 4 + .../gen/saas/accounts/v1/installations_pb.ts | 117 ++++- .../accounts/v1/solution_entitlements_pb.ts | 144 +++++++ .../v1/solution_entitlements_service_pb.ts | 42 ++ .../code/src/solutions/SolutionsMenu.tsx | 96 +++-- .../src/solutions/__tests__/registry.test.ts | 185 +++++++- .../code/src/solutions/entitlements.ts | 244 +++++++++++ .../frontend/code/src/solutions/registry.ts | 193 ++++++++- module/tools/interface-docs-gate.mjs | 1 + .../solution_registration_boundary_test.go | 120 +++++- 69 files changed, 5441 insertions(+), 185 deletions(-) create mode 100644 module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements.proto create mode 100644 module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements_service.proto create mode 100644 module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements.proto create mode 100644 module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements_service.proto create mode 100644 module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto create mode 100644 module/services/accounts/code/pkg/adapters/solution_entitlement_rpcs.go create mode 100644 module/services/accounts/code/pkg/business/solution_entitlements.go create mode 100644 module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/solution_entitlements_service.connect.go create mode 100644 module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go create mode 100644 module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service.pb.go create mode 100644 module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements_service_grpc.pb.go create mode 100644 module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go create mode 100644 module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go create mode 100644 module/services/accounts/proto/saas/accounts/v1/solution_entitlements.proto create mode 100644 module/services/accounts/proto/saas/accounts/v1/solution_entitlements_service.proto create mode 100644 module/services/auth-gateway/code/gateway_solution_entitlements.go create mode 100644 module/services/auth-gateway/code/gateway_solution_entitlements_test.go create mode 100644 module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go create mode 100644 module/services/auth-gateway/proto/saas/accounts/v1/solution_entitlements.proto create mode 100644 module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_pb.ts create mode 100644 module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts create mode 100644 module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_pb.ts create mode 100644 module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_service_pb.ts create mode 100644 module/services/frontend/code/src/solutions/entitlements.ts diff --git a/module/DEPLOYMENT_TOPOLOGY.md b/module/DEPLOYMENT_TOPOLOGY.md index d9c58bc63..a5c3354f4 100644 --- a/module/DEPLOYMENT_TOPOLOGY.md +++ b/module/DEPLOYMENT_TOPOLOGY.md @@ -165,7 +165,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/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 6249f688e..58c91e57a 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -171,7 +171,7 @@ 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 + **route and page exposure**: whether `/s/{id}` renders a remote and whether `/solutions/{id}/…` proxies. It is per-deployment, not per-organization. - **Per-org installation** (`InstallationService/InstallSolution`) governs **agent authority only**. Installing composes an agent principal, a @@ -190,14 +190,55 @@ boundary is deliberate: **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 +for that organization or any other. Its ability to *act* on a tenant's behalf as +an agent is what installation confers and uninstall withdraws. 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. +### What the projections answer (issue #949) + +Registration stays deployment-wide, but the **projections** answer per organization +and per viewer. `GET /api/solutions/register` (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 +solution id: `installations.solution_identifier` is the registered manifest `id`. + +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. + +**Route and page exposure is still deployment-wide.** `/s/{id}` renders and +`/solutions/{id}/…` proxies for any caller the gateway authenticates, whatever any +organization installed. Per-tenant admission *there* would be an additional gate — +a tenant check in the solution page and proxy route — and is not implemented here. ## 5. Rollout 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..8d2f5da0c 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 @@ -139,6 +139,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 +291,26 @@ 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 + }; + } } 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..df911e84b --- /dev/null +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,90 @@ +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 { + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + string solution_identifier = 1; + 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; + // 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/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index de8a54b3e..8d2f5da0c 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 @@ -139,6 +139,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 +291,26 @@ 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 + }; + } } 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..df911e84b --- /dev/null +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,90 @@ +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 { + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + string solution_identifier = 1; + 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; + // 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/auth-gateway/grpc/contract.binpb b/module/contracts/api/auth-gateway/grpc/contract.binpb index 19bc0b85b0661264d88ab055793bc38dcb2d624b..32382d232dab05490af4dc0c20dbd91b84a9f60a 100644 GIT binary patch delta 4621 zcmZ`--)|gO72cU0$GbOaLN{q@HcfovgnFYmn_^L+s@FM$F$@FtiZl0HrbOR zG`-Q3hqbkhVmR|hhfO~Sw0VU`dPDvsk=it-*$EYQ=^}@xM-8071kRsjOefnPOW(Z8 zi+Vn17uE-&D>rrUskCNK++{CUCz}&f|E{(w8#S7IjJd`|#_U3)c4cIZu5ZxRd-4f= znfax|`2V+C4^*UOV~rgNAk43}PR@Vpp#6o{{6Q&p8%LHk-ezYa`H9&q8DImm@fv+z zhl$pP`+vL4PBgqq<->yufA%h?|2)ZDuhQ_QE4Q5H=PrK*q&(1EMUv-Xl^tI2f(xFb z#QS2z6D`|(&0sy_9T^7OG8RULBP<)lihu@VxZ2^SSDcn+G@9d)GNsSi*V|k$?gNyh zFXL<WU$lsv5^eW(u^0ka8hJo zUJ&`gK!jYx61MPxh)s5*Fl{#fqP{PFzO?_fTTdBZm+_6gKn`)s7zoOoJmq*R&^nR) z%B}C*y2cn^DEvMKV zF{LMT$(*!R&Tf6p-aqUGeh+Zru%6OIY_lNh*~5EpP>ORa1F*Rxt?LCm#2}ajs_~W$ znRSt0=ekEhNg|4!O!s6w%8%$sKd=#vCBWRX$Hgk?_O7*o`;(r;f!HZ59-!^^h(G#Z z*}6Rkmn^B_DmTDV<7!+m$3aW2LDS-jb`A|T@j4(tu9knKD~WGD#R(hQ}HEUl%0vRGM8GAYS1Gk12z07YgU4(S{h7X?ck^T-pnoj zug^Xw1Cb}9Pd5lniBeRCSaz`^9<`CiM)8iqO|(O+gWvCammGHR<-T`49R>)0V)!01 z6Fjp7lzgDVz#lQL1_ZEfQ(%BtychVnQ8dW7(6`z#zPFTBdNbdW*)RvJh;zdCRe~}H ztQ3PrS+cj;VYV{Hs$RuuRHthf*eN#QRou!%qk6@yv6l|>Xh@^W&EGlJu>TdgSwwtX z|Hyho27f?{?-IktL9kpwWJOodDLgPb-7Ze`nOXE?l zTBV(Rh{FyF8{Y~#Ti|!@3p~40f2$Z(F`DN2$eC!Q)vIvyr1cXkuevM69F6Kz#R&VZ zUMNm+Y8YL7pM8VX0gqFARzH0nUoI`@t6kvN!m(dv__;K?-~7iB{H%)2!vC@69MAoN za#jc|7MV-}4VwUr_mEj&8_0-2XMh3s5+`WZa=Fd#i8u~yWhw{6Pm#o0fP5KSw!xK- zK%$9GKQKrEEFME&uos_DIH0PbQoG4u47Cf$TJ?fNE+A`Nnl2y#WG%jyL+0j?wJxWt z`GP7in|DvJFDGp*uzB7++x(5kf7r9)YXK-LD^V=(es~;od(d$;ggRwP2jhZ0$#-Ja zw>v3oLA+!7z9LhzR%+I;d!S;YNgWsYwo>5{wIe1EMpX66n^tWLM#3^k;sMkh1?G_Q zndZaRqAKg28X_Ab=rznwse3zw7?mW6#GVL3u^o~yqjXtC%4<2*kFolYz~CJ8gOFCF z3uz&x9aPNvy&{h~mkAOf1i8zrUuqZ#lk@>43PGlf(8O_w3x@^Y6Cr9gr6)+WE9HP> zK2-iL5_gbgJ;T|Z1YHC&+g3yvG)FapjHU)DPT-U^P$!T#Xj*FLtOeaz4Pg~}nrLLJ zH8~4kfF1QdtILZIo~(XU4D-;p+01XNe5u?^QFDUIihStbAR0%c%od<+p>Y`c_pp8E*(^9@=Fm2( zDH7I4?4s007ujS`ixLzed@Luh4T}wQW;a1h6s4;G@;G3y6Z(gpXeM_=*5w>AA5E2I zUAc3a)hxAfDjqAu9@`)A$Be|6DKYwXu96vrX)T6FI=f8l23H+%Qw4M!;=fC!q3 zjIpgdrTVxv26Jr-3RnvW>Rj^I$PC5b4-?ym+3q%Tf%Iz! zEAz+!9fGG`VCw+s#q)0#NCN4_sl^=Wq8u)M^ZWTcmBS@>lKqOxq2sy@Z^>PmXg+iJ zx=8U(u|=oN-{CjjvuzI`>c}0Dgys&C1mvRklvTR&1AwX#+=x)S%4I=kd6kQ%Nn-S( zq_y@EoCN%EFt%7hLj4QFv6IAsrP}L-*>G~{+#BpQ^hB__2D8tvc<{1jhtKL&e0g)F zn)^*Pjf&W^$I4%D$ot-@{79N#1C@Vdfxy&S>P|Hu@kaxJH#zqc#3s(fo1%#}RweWb z$sWEa{Kp=!cev~i=(U1f^sFVH#@&8< delta 23 fcmdn_LFCCvk%kt=7N!>FEi7gmw|5_5Syv1Ifi4PL 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..df911e84b --- /dev/null +++ b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,90 @@ +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 { + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + string solution_identifier = 1; + 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; + // 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/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index cd24cd65c..f9781aab9 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: **239 RPCs** across **35 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. +Inventory: **241 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,7 @@ 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/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). | @@ -198,6 +199,7 @@ 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.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/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.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. | @@ -251,7 +253,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit ## Tier totals - `auth`: 43 -- `internal`: 49 +- `internal`: 51 - `mfa`: 3 - `org_admin`: 54 - `org_member`: 48 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..e6b7474df 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,7 @@ 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) +} 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/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..a16b3d2d0 100644 --- a/module/services/accounts/code/pkg/adapters/installation_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/installation_rpcs.go @@ -105,6 +105,24 @@ 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 +} + func mapInstallationError(err error) error { if err == nil { return nil 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/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/business/installations.go b/module/services/accounts/code/pkg/business/installations.go index bf3505a3e..6dbe67604 100644 --- a/module/services/accounts/code/pkg/business/installations.go +++ b/module/services/accounts/code/pkg/business/installations.go @@ -54,6 +54,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) } func (s *Service) installationStore() InstallationStore { @@ -144,6 +149,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. diff --git a/module/services/accounts/code/pkg/business/introspection.go b/module/services/accounts/code/pkg/business/introspection.go index 9e625a169..9b91073ff 100644 --- a/module/services/accounts/code/pkg/business/introspection.go +++ b/module/services/accounts/code/pkg/business/introspection.go @@ -139,6 +139,8 @@ 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.", + "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.", diff --git a/module/services/accounts/code/pkg/business/layered_access.go b/module/services/accounts/code/pkg/business/layered_access.go index 383e59c18..a572bb12f 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 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 92e249251..9de097abc 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 @@ -37,6 +37,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", 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 ad45e7d37..140531f25 100644 --- a/module/services/accounts/code/pkg/business/service_catalog_test.go +++ b/module/services/accounts/code/pkg/business/service_catalog_test.go @@ -22,8 +22,8 @@ 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(), 239) + require.Len(t, catalog.GetServices(), 36) + require.Len(t, catalog.GetMethods(), 241) require.Len(t, catalog.GetPermissions(), 24) require.Len(t, catalog.GetEntitlements(), 5) require.Equal(t, "*:*", catalog.GetPermissions()[0].GetPermission()) 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/cataloggen/authz_methods_test.go b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go index d209f5ecd..15d4fc707 100644 --- a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go +++ b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go @@ -40,7 +40,7 @@ func TestAuthorizationCatalogCompilationAndPolicyProjection(t *testing.T) { require.Contains(t, method.GetProcedure(), "Complete") } } - require.Equal(t, 49, internalCount) + require.Equal(t, 51, internalCount) require.Equal(t, 17, failClosedCount) require.Equal(t, 2, factorAttemptCount) require.Equal( 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 68bd38708..fe22ba218 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/CancelApproval - /saas.accounts.v1.ModuleCapabilitiesService/CheckWorkContextRecordAccess @@ -69,6 +70,7 @@ spec: - /saas.accounts.v1.PrincipalService/EnableAgentPrincipal - /saas.accounts.v1.PrincipalService/GetAgentPrincipal - /saas.accounts.v1.PrincipalService/GetPrincipal + - /saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements - /saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration - /saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations - /saas.accounts.v1.SolutionRegistryService/PutSolutionRegistration 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..f315adfad 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,9 @@ 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" ) // InstallationServiceClient is a client for the saas.accounts.v1.InstallationService service. @@ -70,6 +73,15 @@ 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) } // NewInstallationServiceClient constructs a client for the saas.accounts.v1.InstallationService @@ -107,6 +119,12 @@ 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...), + ), } } @@ -116,6 +134,7 @@ 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] } // InstallSolution calls saas.accounts.v1.InstallationService.InstallSolution. @@ -139,6 +158,11 @@ 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) +} + // InstallationServiceHandler is an implementation of the saas.accounts.v1.InstallationService // service. type InstallationServiceHandler interface { @@ -161,6 +185,15 @@ 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) } // NewInstallationServiceHandler builds an HTTP handler from the service implementation. It returns @@ -194,6 +227,12 @@ 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...), + ) return "/saas.accounts.v1.InstallationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case InstallationServiceInstallSolutionProcedure: @@ -204,6 +243,8 @@ func NewInstallationServiceHandler(svc InstallationServiceHandler, opts ...conne installationServiceTransferInstallationOwnershipHandler.ServeHTTP(w, r) case InstallationServiceGetInstallationProcedure: installationServiceGetInstallationHandler.ServeHTTP(w, r) + case InstallationServiceListInstallationsProcedure: + installationServiceListInstallationsHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -228,3 +269,7 @@ 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")) +} 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/installations.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go index 10fa9cdea..32361ee9d 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 @@ -619,6 +619,194 @@ 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 "" +} + var File_saas_accounts_v1_installations_proto protoreflect.FileDescriptor const file_saas_accounts_v1_installations_proto_rawDesc = "" + @@ -666,7 +854,20 @@ 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*z\n" + "\x12InstallationStatus\x12#\n" + "\x1fINSTALLATION_STATUS_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aINSTALLATION_STATUS_ACTIVE\x10\x01\x12\x1f\n" + @@ -677,7 +878,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\xf8\a\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 +890,8 @@ 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`\x01B\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 +907,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, 9) 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 +917,37 @@ 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 + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 12: 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 + 11, // 1: saas.accounts.v1.Installation.created_at:type_name -> google.protobuf.Timestamp + 11, // 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 + 3, // 9: saas.accounts.v1.InstallationService.InstallSolution:input_type -> saas.accounts.v1.InstallSolutionRequest + 4, // 10: saas.accounts.v1.InstallationService.UninstallSolution:input_type -> saas.accounts.v1.UninstallSolutionRequest + 5, // 11: saas.accounts.v1.InstallationService.TransferInstallationOwnership:input_type -> saas.accounts.v1.TransferInstallationOwnershipRequest + 6, // 12: saas.accounts.v1.InstallationService.GetInstallation:input_type -> saas.accounts.v1.GetInstallationRequest + 8, // 13: saas.accounts.v1.InstallationService.ListInstallations:input_type -> saas.accounts.v1.ListInstallationsRequest + 2, // 14: saas.accounts.v1.InstallationService.InstallSolution:output_type -> saas.accounts.v1.Installation + 12, // 15: saas.accounts.v1.InstallationService.UninstallSolution:output_type -> google.protobuf.Empty + 2, // 16: saas.accounts.v1.InstallationService.TransferInstallationOwnership:output_type -> saas.accounts.v1.Installation + 7, // 17: saas.accounts.v1.InstallationService.GetInstallation:output_type -> saas.accounts.v1.GetInstallationResponse + 10, // 18: saas.accounts.v1.InstallationService.ListInstallations:output_type -> saas.accounts.v1.ListInstallationsResponse + 14, // [14:19] is the sub-list for method output_type + 9, // [9:14] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_saas_accounts_v1_installations_proto_init() } @@ -751,7 +962,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: 9, NumExtensions: 0, NumServices: 1, }, 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..90bda51b2 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,7 @@ 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" ) // InstallationServiceClient is the client API for InstallationService service. @@ -54,6 +55,15 @@ 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) } type installationServiceClient struct { @@ -104,6 +114,16 @@ 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 +} + // InstallationServiceServer is the server API for InstallationService service. // All implementations must embed UnimplementedInstallationServiceServer // for forward compatibility. @@ -131,6 +151,15 @@ 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) mustEmbedUnimplementedInstallationServiceServer() } @@ -153,6 +182,9 @@ 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) mustEmbedUnimplementedInstallationServiceServer() {} func (UnimplementedInstallationServiceServer) testEmbeddedByValue() {} @@ -246,6 +278,24 @@ 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) +} + // 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 +319,10 @@ var InstallationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetInstallation", Handler: _InstallationService_GetInstallation_Handler, }, + { + MethodName: "ListInstallations", + Handler: _InstallationService_ListInstallations_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "saas/accounts/v1/installations.proto", 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..8ea84de6a --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go @@ -0,0 +1,314 @@ +// 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"` + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + SolutionIdentifier string `protobuf:"bytes,1,opt,name=solution_identifier,json=solutionIdentifier,proto3" json:"solution_identifier,omitempty"` + 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"` + 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) GetSolutionIdentifier() string { + if x != nil { + return x.SolutionIdentifier + } + return "" +} + +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 +} + +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\"\xca\x01\n" + + "\x13SolutionEntitlement\x12/\n" + + "\x13solution_identifier\x18\x01 \x01(\tR\x12solutionIdentifier\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\"\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/infra/postgres_installations.go b/module/services/accounts/code/pkg/infra/postgres_installations.go index 950b95f48..bcf91ba2c 100644 --- a/module/services/accounts/code/pkg/infra/postgres_installations.go +++ b/module/services/accounts/code/pkg/infra/postgres_installations.go @@ -12,6 +12,7 @@ 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" ) @@ -323,6 +324,92 @@ 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 solution_identifier or created_at because it is the +// only column that is unique across the whole table: (org_id, solution_identifier) +// 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() + } + + rows, err := executor.Query(ctx, + `SELECT `+installationColumns+` + FROM installations + WHERE org_id = $1 + AND ($2 = '' OR status = $2) + AND ($3 = '' OR id::text > $3) + ORDER BY id + LIMIT $4`, + orgID, installationStatusColumn(status), cursor, limit) + if err != nil { + return nil, w.Wrapf(err, "failed to list installations") + } + defer rows.Close() + + var installations []*gen.Installation + for rows.Next() { + installation, e := scanInstallation(rows) + if e != nil { + return nil, w.Wrapf(e, "failed to scan installation") + } + installations = append(installations, installation) + } + if e := rows.Err(); e != nil { + return nil, w.Wrapf(e, "failed to read installations") + } + // Health resolution runs after the cursor is exhausted, never inside the + // loop: it issues its own queries on the same connection, and pgx will not + // serve those while rows from this query are still being read. + summaries := make([]*gen.InstallationSummary, 0, len(installations)) + for _, installation := range installations { + health, e := s.resolveInstallationHealth(ctx, executor, installation) + if e != nil { + return nil, w.Wrapf(e, "failed to resolve installation health") + } + summaries = append(summaries, &gen.InstallationSummary{Installation: installation, Health: health}) + } + return summaries, nil +} + // 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) { 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..bf0b1ee56 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go @@ -0,0 +1,120 @@ +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 — then $7 cursor and $8 limit, appended by + // this query. + // + // 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. + rows, err := executor.Query(ctx, + `SELECT `+installationColumns+` + FROM installations + WHERE org_id = $4 + AND status = 'active' + AND root_scope_node_id::text IN (`+admitted+`) + AND ($7 = '' OR id::text > $7) + ORDER BY id + LIMIT $8`, + subjectID, business.ResourceTypeSolution, business.ActionUseSolution, orgID, + business.ScopeNodeKindSolution, + platformReadAdmissible(ctx, gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, business.ActionUseSolution), + cursor, limit) + if err != nil { + return nil, w.Wrapf(err, "failed to list solution entitlements") + } + defer rows.Close() + + var installations []*gen.Installation + for rows.Next() { + installation, e := scanInstallation(rows) + if e != nil { + return nil, w.Wrapf(e, "failed to scan entitled installation") + } + installations = append(installations, installation) + } + if e := rows.Err(); e != nil { + return nil, w.Wrapf(e, "failed to read solution entitlements") + } + + // Health resolution issues its own queries, so it runs after this cursor is + // drained rather than inside the loop. + out := make([]*gen.SolutionEntitlement, 0, len(installations)) + for _, installation := range installations { + health, e := s.resolveInstallationHealth(ctx, executor, installation) + if e != nil { + return nil, w.Wrapf(e, "failed to resolve entitled installation health") + } + out = append(out, &gen.SolutionEntitlement{ + SolutionIdentifier: installation.GetSolutionIdentifier(), + 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: health == 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..0f5a52ef6 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go @@ -0,0 +1,405 @@ +//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 installs one solution into org and returns it. 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. +func installSolution(t *testing.T, orgID, ownerID, roleID, identifier 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/" + identifier + ":1.0.0", + SolutionIdentifier: identifier, + RootScopeLabel: identifier, + 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.GetSolutionIdentifier()) + } + 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"}, entitledIdentifiers(t, orgID, reviewer)) + require.Equal(t, []string{"ledger"}, 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", "ledger"}, entitledIdentifiers(t, orgID, member)) + + revokeAtNode(t, orgID, team, roleID, ledger) + require.Equal(t, []string{"audit"}, 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"}, 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{"audit"}, 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) + installSolution(t, orgID, ownerID, roleID, "audit") + 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().GetSolutionIdentifier()) + require.Equal(t, gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, summary.GetHealth()) + } + require.ElementsMatch(t, []string{"audit", "ledger"}, 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") + 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", active[0].GetInstallation().GetSolutionIdentifier()) + + revoked := listInstallations(t, orgID, gen.InstallationStatus_INSTALLATION_STATUS_REVOKED, "", 100) + require.Len(t, revoked, 1) + require.Equal(t, "audit", revoked[0].GetInstallation().GetSolutionIdentifier()) + + 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 +// solution_identifier 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().GetSolutionIdentifier()]++ + } + 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) + 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, "audit", a[0].GetInstallation().GetSolutionIdentifier()) +} + +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") +} diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index 0ccc1fbde..b493e4e1b 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -3125,6 +3125,43 @@ "policy_sha256": "f44d4ffd9edf43aa97546d85433b347bfcaa1d5d3d870b0a6dbc2a65431b638e", "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", @@ -7770,6 +7807,43 @@ "policy_sha256": "406b4292c916a9b7c1cf1394b27b3a047ab7958711bcb37a03dc7e7b822e3f00", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, + { + "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": { + "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/solution_entitlements_service.proto", + "policy_sha256": "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, { "procedure": "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", "full_service": "saas.accounts.v1.SolutionRegistryService", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index edde4eaef..6e89f9816 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -168,6 +168,7 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -359,6 +360,13 @@ "/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", @@ -4344,6 +4352,49 @@ "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", @@ -10307,6 +10358,49 @@ "service": "accounts" } }, + { + "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_entitlements_service.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 the installed solutions a subject may use in one organization.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, { "procedure": "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration", "service": "SolutionRegistryService", diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index de8a54b3e..8d2f5da0c 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -139,6 +139,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 +291,26 @@ 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 + }; + } } 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..df911e84b --- /dev/null +++ b/module/services/accounts/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,90 @@ +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 { + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + string solution_identifier = 1; + 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; + // 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/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index b6edbf415..f165883de 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -84,6 +84,7 @@ 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/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"}, @@ -201,6 +202,7 @@ 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.SolutionEntitlementService/ListSolutionEntitlements": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, "/saas.accounts.v1.SolutionRegistryService/DeleteSolutionRegistration": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "f7ede710e2635a205b5ffc3e8079f7702560255d5b4c875d7b6af8e041998023"}, "/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"}, diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index ce5d5e712..3b08679f2 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -58,6 +58,11 @@ type Gateway struct { registeredTransport http.RoundTripper // registrationReplay makes each solution-registration credential single-use. registrationReplay *registrationReplayGuard + // 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 } 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..c19881ffb --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -0,0 +1,236 @@ +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" + +// 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 installed solution's identifier, which for this host's registry is the + // registered manifest id a consumer joins its own set against. + ID string `json:"id"` + // 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")) { + 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 := r.Header.Get("X-User-Id") + 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 []. + httpError(w, http.StatusForbidden, "no organization in this session") + return + } + + 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, + }) +} + +// 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{ + ID: entitlement.GetSolutionIdentifier(), + Healthy: entitlement.GetHealthy(), + ScopeNodeID: entitlement.GetRootScopeNodeId(), + }) + } + token = resp.GetNextPageToken() + if token == "" { + return entries, nil + } + } + return nil, errSolutionEntitlementsUnbounded +} 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..50e563f75 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "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{ + {SolutionIdentifier: "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{ + {ID: "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{ + {SolutionIdentifier: "audit", RootScopeNodeId: "node-1", Healthy: true}, + }, + NextPageToken: "cursor-1", + }, + { + Entitlements: []*accountsv1.SolutionEntitlement{ + {SolutionIdentifier: "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{ + {ID: "audit", Healthy: true, ScopeNodeID: "node-1"}, + // Carried, not dropped: an unhealthy installation stays visible and is + // marked unavailable by the consumer. + {ID: "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{ + {SolutionIdentifier: "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) +} diff --git a/module/services/auth-gateway/code/gateway_solutions.go b/module/services/auth-gateway/code/gateway_solutions.go index 07e9ad6ad..91255a77f 100644 --- a/module/services/auth-gateway/code/gateway_solutions.go +++ b/module/services/auth-gateway/code/gateway_solutions.go @@ -83,6 +83,16 @@ func (g *Gateway) handleSolutionRequest(w http.ResponseWriter, r *http.Request) 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 == "" { httpError(w, http.StatusNotFound, "solution not specified") 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 4719a19e2..9393dfcab 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -148,6 +148,11 @@ var httpAllowedPerimeterFiles = map[string]bool{ // upstreams. 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_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..49e53aaff --- /dev/null +++ b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/solution_entitlements.pb.go @@ -0,0 +1,314 @@ +// 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"` + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + SolutionIdentifier string `protobuf:"bytes,1,opt,name=solution_identifier,json=solutionIdentifier,proto3" json:"solution_identifier,omitempty"` + 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"` + 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) GetSolutionIdentifier() string { + if x != nil { + return x.SolutionIdentifier + } + return "" +} + +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 +} + +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\"\xca\x01\n" + + "\x13SolutionEntitlement\x12/\n" + + "\x13solution_identifier\x18\x01 \x01(\tR\x12solutionIdentifier\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\"\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/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..df911e84b --- /dev/null +++ b/module/services/auth-gateway/proto/saas/accounts/v1/solution_entitlements.proto @@ -0,0 +1,90 @@ +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 { + // The installed solution's identifier. This is the key a consumer joins + // against its own registered set — for the host's solution registry, the + // registered manifest `id`. + string solution_identifier = 1; + 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; + // 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/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index 9679f488b..79663f55a 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -37,15 +37,21 @@ activating a remote. ## 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/register` 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 @@ -55,6 +61,25 @@ 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. + +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 simply is not routed as serving. The projection cache is +keyed on organization, viewer, a digest of the entitlement answer, and client kind, +so a grant or a revoke moves the key and a cached menu cannot outlive the grant +that justified it. - 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/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..7424a067d 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("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEilwMKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEhsKE3NvbHV0aW9uX2lkZW50aWZpZXIYBCABKAkSJAoSb3duZXJfcHJpbmNpcGFsX2lkGAUgASgJQgi6SAVyA7ABARIeChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAYgAygJEiQKEnJvb3Rfc2NvcGVfbm9kZV9pZBgHIAEoCUIIukgFcgOwAQESNAoGc3RhdHVzGAggASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdGF0dXMSLgoKY3JlYXRlZF9hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoKcmV2b2tlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBAUINCgtfcmV2b2tlZF9hdCKmAwoWSW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiQKEGFnZW50X2lkZW50aWZpZXIYAiABKAlCCrpIB3IFEAEYgAQSJwoTc29sdXRpb25faWRlbnRpZmllchgDIAEoCUIKukgHcgUQARiABBIeCgxkaXNwbGF5X25hbWUYBCABKAlCCLpIBXIDGIAEEiIKEHJvb3Rfc2NvcGVfbGFiZWwYBiABKAlCCLpIBXIDGIAEEhkKB3JvbGVfaWQYByABKAlCCLpIBXIDsAEBEicKEm93bmVyX3ByaW5jaXBhbF9pZBgIIAEoCUILukgI2AEBcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgJIAMoCUIPukgMkgEJECAiBXIDsAEBEioKEWFsbG93ZWRfYXVkaWVuY2VzGAogAygJQg+6SAySAQkQQCIFcgMYgAQSJwoOYWxsb3dlZF9zY29wZXMYCyADKAlCD7pIDJIBCRBAIgVyAxiAAUoECAUQBlIPcm9vdF9zY29wZV9wYXRoIlcKGFVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEivgEKJFRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESKAoWbmV3X293bmVyX3ByaW5jaXBhbF9pZBgDIAEoCUIIukgFcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgEIAMoCUIPukgMkgEJECAiBXIDsAEBIlUKFkdldEluc3RhbGxhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBIoUBChdHZXRJbnN0YWxsYXRpb25SZXNwb25zZRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCKdAQoYTGlzdEluc3RhbGxhdGlvbnNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESNAoGc3RhdHVzGAIgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdGF0dXMSHQoJcGFnZV9zaXplGAMgASgFQgq6SAcaBRj0AygAEhIKCnBhZ2VfdG9rZW4YBCABKAkigQEKE0luc3RhbGxhdGlvblN1bW1hcnkSNAoMaW5zdGFsbGF0aW9uGAEgASgLMh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24SNAoGaGVhbHRoGAIgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25IZWFsdGgicgoZTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZRI8Cg1pbnN0YWxsYXRpb25zGAEgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdW1tYXJ5EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSp6ChJJbnN0YWxsYXRpb25TdGF0dXMSIwofSU5TVEFMTEFUSU9OX1NUQVRVU19VTlNQRUNJRklFRBAAEh4KGklOU1RBTExBVElPTl9TVEFUVVNfQUNUSVZFEAESHwobSU5TVEFMTEFUSU9OX1NUQVRVU19SRVZPS0VEEAIqhAIKEkluc3RhbGxhdGlvbkhlYWx0aBIjCh9JTlNUQUxMQVRJT05fSEVBTFRIX1VOU1BFQ0lGSUVEEAASHwobSU5TVEFMTEFUSU9OX0hFQUxUSF9IRUFMVEhZEAESKQolSU5TVEFMTEFUSU9OX0hFQUxUSF9OT19FTElHSUJMRV9PV05FUhACEiUKIUlOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfUkVWT0tFRBADEiYKIklOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfRElTQUJMRUQQBBIuCipJTlNUQUxMQVRJT05fSEVBTFRIX1NUQU5ESU5HX0dSQU5UX01JU1NJTkcQBTL4BwoTSW5zdGFsbGF0aW9uU2VydmljZRK6AQoPSW5zdGFsbFNvbHV0aW9uEiguc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsU29sdXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24iXcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5jcmVhdGVkEAJAAUgEUANYA2ABgtPkkwIWOgEqIhEvdjEvaW5zdGFsbGF0aW9ucxLSAQoRVW5pbnN0YWxsU29sdXRpb24SKi5zYWFzLmFjY291bnRzLnYxLlVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSJ5wvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLnJldm9rZWQQAkABSARQA1gDYAGC0+STAjI6ASoiLS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9OnVuaW5zdGFsbBKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfRKGAQoRTGlzdEluc3RhbGxhdGlvbnMSKi5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABYgZwcm90bzM", [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; @@ -270,6 +270,104 @@ 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); + /** * 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 +505,22 @@ 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; + }, }> = /*@__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/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..c6c9a5b6b --- /dev/null +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/solution_entitlements_pb.ts @@ -0,0 +1,143 @@ +// @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("CixzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX2VudGl0bGVtZW50cy5wcm90bxIQc2Fhcy5hY2NvdW50cy52MSKMAQoTU29sdXRpb25FbnRpdGxlbWVudBIbChNzb2x1dGlvbl9pZGVudGlmaWVyGAEgASgJEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESJAoScm9vdF9zY29wZV9ub2RlX2lkGAMgASgJQgi6SAVyA7ABARIPCgdoZWFsdGh5GAQgASgIIowBCh9MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoKc3ViamVjdF9pZBgCIAEoCUIIukgFcgOwAQESHQoJcGFnZV9zaXplGAMgASgFQgq6SAcaBRj0AygAEhIKCnBhZ2VfdG9rZW4YBCABKAkieAogTGlzdFNvbHV0aW9uRW50aXRsZW1lbnRzUmVzcG9uc2USOwoMZW50aXRsZW1lbnRzGAEgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkVudGl0bGVtZW50EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCWIGcHJvdG8z", [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"> & { + /** + * The installed solution's identifier. This is the key a consumer joins + * against its own registered set — for the host's solution registry, the + * registered manifest `id`. + * + * @generated from field: string solution_identifier = 1; + */ + solutionIdentifier: string; + + /** + * @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. + * + * 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: bool healthy = 4; + */ + healthy: boolean; +}; + +/** + * 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/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..6721e29b6 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 @@ -53,6 +53,20 @@ function fakeGateway() { })), }); } + // 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) => ({ + id, + healthy: true, + scopeNodeId: `node-${id}`, + })), + }); + } return respond({ error: "unexpected" }, 500); }); } @@ -224,10 +238,15 @@ describe("internal solution detail lookup", () => { expect(audit).not.toHaveProperty("dashboard"); }); - it("serves detail the public navigation projection withholds", async () => { + it("serves detail the viewer navigation projection withholds", async () => { await register(); - const publicBody = (await publicGET().then((r) => r.json())) as { + const viewerRequest = new Request("http://frontend/api/solutions/register", { + 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/solutions/register/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts index c2cec3bfe..d24a85659 100644 --- 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 @@ -63,6 +63,17 @@ function credential(solution = "audit", subject = `solution:${solution}`): strin } const GATEWAY = "http://gateway.internal:8080"; +/** + * The nav projection is authenticated (#949): it forwards the caller's credential + * to the gateway, which is what decides whose menu comes back. These tests are + * about registration, so they present one and assert the narrowing elsewhere. + */ +function navRequest(credential = "Bearer viewer-token"): Request { + const headers = new Headers(); + if (credential) headers.set("authorization", credential); + return new Request("http://frontend/api/solutions/register", { headers }); +} + // 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. @@ -101,6 +112,24 @@ function fakeGateway() { })), }); } + // The per-viewer entitlement read the nav projection now narrows on (#949). + // Every registered solution is entitled here: these tests are about + // registration, so the narrowing must be exercised (the route is + // authenticated) without becoming the subject. + if (url.pathname === "/solutions/_entitlements") { + if (!new Headers(init?.headers).get("authorization")) { + return respond({ error: "unauthenticated" }, 401); + } + return respond({ + org: "org-acme", + viewer: "viewer-1", + solutions: [...stored.keys()].map((id) => ({ + id, + healthy: true, + scopeNodeId: `node-${id}`, + })), + }); + } return respond({ error: "unexpected" }, 500); }); } @@ -513,7 +542,7 @@ describe("solutions register route auth", () => { }), ); resetRegistryCache(); - expect((await GET()).status).toBe(503); + expect((await GET(navRequest())).status).toBe(503); }); it("rejects an authenticated POST carrying an unsafe manifest", async () => { @@ -525,9 +554,12 @@ describe("solutions register route auth", () => { expect(res.status).toBe(422); }); - it("lets the browser GET the nav list without a token", async () => { + it("lets a signed-in browser GET the nav list without the internal token", async () => { + // The cluster-internal token gates the DETAIL projection, not this one. What + // this one requires is the viewer's own credential, because the list it + // answers is that viewer's (#949). getWorkspaceSecret.mockReturnValue(TOKEN); - const res = await GET(); + const res = await GET(navRequest()); expect(res.status).toBe(200); await expect(res.json()).resolves.toHaveProperty("solutions"); }); @@ -559,7 +591,7 @@ describe("solutions register route auth", () => { }; expect((await POST(postRequest(body, TOKEN))).status).toBe(200); - const listed = (await GET().then((r) => r.json())) as { + const listed = (await GET(navRequest()).then((r) => r.json())) as { solutions: Array>; }; const audit = listed.solutions.find((s) => s.id === "audit"); @@ -569,7 +601,11 @@ describe("solutions register route auth", () => { // 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"]); + expect(Object.keys(audit ?? {}).sort()).toEqual([ + "available", + "id", + "nav", + ]); }); it("keeps a mutated nav projection out of the stored manifest", async () => { @@ -586,13 +622,17 @@ describe("solutions register route auth", () => { const stored = await findSolution("audit"); expect(stored).not.toBeNull(); expect(stored).not.toBe("unavailable"); - const projected = navProjection(stored as SolutionManifest); + const projected = navProjection(stored as SolutionManifest, { + id: "audit", + healthy: true, + scopeNodeId: "node-audit", + }); projected.nav.title = "Tampered"; expect((await findSolution("audit")) as SolutionManifest).toMatchObject({ nav: { title: "Audit" }, }); - const listed = (await GET().then((r) => r.json())) as { + const listed = (await GET(navRequest()).then((r) => r.json())) as { solutions: Array<{ id: string; nav: { title: string } }>; }; expect(listed.solutions.find((s) => s.id === "audit")?.nav.title).toBe( 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 index 7179da700..d025f497e 100644 --- a/module/services/frontend/code/src/app/api/solutions/register/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/register/route.ts @@ -12,8 +12,11 @@ import { observeRegistrationRemoved, type RegistrationRefusal, } from "@/solutions/registration-log"; +import { viewerEntitlements } from "@/solutions/entitlements"; import { - loadSolutions, + cachedProjection, + entitledSolutions, + loadSolutionsWithRevision, navProjection, parseManifest, registerSolution, @@ -288,20 +291,53 @@ export async function DELETE(request: Request): Promise { 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(); +// GET is the navigation projection for ONE VIEWER: the id, the nav entry the +// browser polls to render the Solutions menu, and whether it can be opened — +// nothing else. It carries 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. +// +// It is authenticated (issue #949). It used to be ungated and answered every +// caller the same deployment-wide set, so the menu showed what was *registered* +// rather than what the viewer may *use*. The verified organization and viewer it +// now narrows on exist only behind the gateway's ext_authz — see +// solutions/entitlements.ts for why nothing in this handler may derive them — and +// an unauthenticated caller gets 401 rather than an empty menu, which would be +// indistinguishable from an organization that installed nothing. +export async function GET(request: Request): Promise { + const entitlements = await viewerEntitlements(request); + if (entitlements === "unauthenticated") { + return Response.json({ error: "unauthenticated" }, { status: 401 }); + } + if (entitlements === "forbidden") { + return Response.json({ error: "no_organization" }, { status: 403 }); + } + if (entitlements === "unavailable") { + // The authority could not answer. Never an empty list: the menu holds its + // last known set rather than emptying on an authority blip, exactly as it + // does for an unreadable registry. + return Response.json({ error: "authority_unavailable" }, { status: 503 }); + } + 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 }); } - return Response.json({ solutions: registered.map(navProjection) }); + // 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..42792cf90 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,12 +33,58 @@ 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; + 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 }>, + 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) { + return new Response(JSON.stringify({ error: "refused" }), { + status: entitlements.status, + }); + } + // 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) => ({ + 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, }); @@ -52,14 +98,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 +168,7 @@ describe("solutions surfaces route", () => { id: "audit", title: "AUDIT", origin: "https://audit.internal", + available: true, surfaces: [WORD_SURFACE], }, ], @@ -184,9 +239,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 +261,7 @@ describe("solutions surfaces route", () => { solutions: Array>; }; expect(Object.keys(parsed.solutions[0] ?? {}).sort()).toEqual([ + "available", "id", "origin", "surfaces", @@ -232,9 +288,27 @@ 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: [ + { id: "audit", healthy: true, scopeNodeId: "node-audit" }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } throw new Error("unreachable"); }), ); @@ -242,4 +316,137 @@ 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 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "no_organization" }); + }); + + 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..8f2699e8a 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,42 @@ import { requestPublicOrigin } from "@/lib/public-origin"; +import { viewerEntitlements } from "@/solutions/entitlements"; import { + cachedProjection, + entitledSolutions, isClientKind, - loadSolutions, + loadSolutionsWithRevision, surfacesProjection, + 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 +53,22 @@ 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 (entitlements === "unauthenticated") { + return Response.json({ error: "unauthenticated" }, { status: 401 }); + } + if (entitlements === "forbidden") { + return Response.json({ error: "no_organization" }, { status: 403 }); + } + if (entitlements === "unavailable") { + // The authority could not answer. Distinct from an empty entitlement set + // for the same reason an unreadable registry is distinct from an empty one: + // answering [] would silently retract every surface a client is showing. + return Response.json({ error: "authority_unavailable" }, { status: 503 }); + } + 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 +78,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/gen/saas/accounts/v1/frontend_catalog.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/frontend_catalog.ts index 3f78cafa9..cfa8815d3 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..a87f93094 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("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEilwMKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEhsKE3NvbHV0aW9uX2lkZW50aWZpZXIYBCABKAkSJAoSb3duZXJfcHJpbmNpcGFsX2lkGAUgASgJQgi6SAVyA7ABARIeChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAYgAygJEiQKEnJvb3Rfc2NvcGVfbm9kZV9pZBgHIAEoCUIIukgFcgOwAQESNAoGc3RhdHVzGAggASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdGF0dXMSLgoKY3JlYXRlZF9hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoKcmV2b2tlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBAUINCgtfcmV2b2tlZF9hdCKmAwoWSW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiQKEGFnZW50X2lkZW50aWZpZXIYAiABKAlCCrpIB3IFEAEYgAQSJwoTc29sdXRpb25faWRlbnRpZmllchgDIAEoCUIKukgHcgUQARiABBIeCgxkaXNwbGF5X25hbWUYBCABKAlCCLpIBXIDGIAEEiIKEHJvb3Rfc2NvcGVfbGFiZWwYBiABKAlCCLpIBXIDGIAEEhkKB3JvbGVfaWQYByABKAlCCLpIBXIDsAEBEicKEm93bmVyX3ByaW5jaXBhbF9pZBgIIAEoCUILukgI2AEBcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgJIAMoCUIPukgMkgEJECAiBXIDsAEBEioKEWFsbG93ZWRfYXVkaWVuY2VzGAogAygJQg+6SAySAQkQQCIFcgMYgAQSJwoOYWxsb3dlZF9zY29wZXMYCyADKAlCD7pIDJIBCRBAIgVyAxiAAUoECAUQBlIPcm9vdF9zY29wZV9wYXRoIlcKGFVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEivgEKJFRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESKAoWbmV3X293bmVyX3ByaW5jaXBhbF9pZBgDIAEoCUIIukgFcgOwAQESLwoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgEIAMoCUIPukgMkgEJECAiBXIDsAEBIlUKFkdldEluc3RhbGxhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBIoUBChdHZXRJbnN0YWxsYXRpb25SZXNwb25zZRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCKdAQoYTGlzdEluc3RhbGxhdGlvbnNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESNAoGc3RhdHVzGAIgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdGF0dXMSHQoJcGFnZV9zaXplGAMgASgFQgq6SAcaBRj0AygAEhIKCnBhZ2VfdG9rZW4YBCABKAkigQEKE0luc3RhbGxhdGlvblN1bW1hcnkSNAoMaW5zdGFsbGF0aW9uGAEgASgLMh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24SNAoGaGVhbHRoGAIgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25IZWFsdGgicgoZTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZRI8Cg1pbnN0YWxsYXRpb25zGAEgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb25TdW1tYXJ5EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSp6ChJJbnN0YWxsYXRpb25TdGF0dXMSIwofSU5TVEFMTEFUSU9OX1NUQVRVU19VTlNQRUNJRklFRBAAEh4KGklOU1RBTExBVElPTl9TVEFUVVNfQUNUSVZFEAESHwobSU5TVEFMTEFUSU9OX1NUQVRVU19SRVZPS0VEEAIqhAIKEkluc3RhbGxhdGlvbkhlYWx0aBIjCh9JTlNUQUxMQVRJT05fSEVBTFRIX1VOU1BFQ0lGSUVEEAASHwobSU5TVEFMTEFUSU9OX0hFQUxUSF9IRUFMVEhZEAESKQolSU5TVEFMTEFUSU9OX0hFQUxUSF9OT19FTElHSUJMRV9PV05FUhACEiUKIUlOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfUkVWT0tFRBADEiYKIklOU1RBTExBVElPTl9IRUFMVEhfQUdFTlRfRElTQUJMRUQQBBIuCipJTlNUQUxMQVRJT05fSEVBTFRIX1NUQU5ESU5HX0dSQU5UX01JU1NJTkcQBTL4BwoTSW5zdGFsbGF0aW9uU2VydmljZRK6AQoPSW5zdGFsbFNvbHV0aW9uEiguc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsU29sdXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24iXcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5jcmVhdGVkEAJAAUgEUANYA2ABgtPkkwIWOgEqIhEvdjEvaW5zdGFsbGF0aW9ucxLSAQoRVW5pbnN0YWxsU29sdXRpb24SKi5zYWFzLmFjY291bnRzLnYxLlVuaW5zdGFsbFNvbHV0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSJ5wvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLnJldm9rZWQQAkABSARQA1gDYAGC0+STAjI6ASoiLS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9OnVuaW5zdGFsbBKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfRKGAQoRTGlzdEluc3RhbGxhdGlvbnMSKi5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABQroBChRjb20uc2Fhcy5hY2NvdW50cy52MUISSW5zdGFsbGF0aW9uc1Byb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [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; @@ -270,6 +270,104 @@ export type GetInstallationResponse = Message<"saas.accounts.v1.GetInstallationR export const GetInstallationResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_installations, 5); +/** + * ListInstallationsRequest asks what one organisation 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-organisation 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 organisation 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); + /** * 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 +505,23 @@ export const InstallationService: GenService<{ input: typeof GetInstallationRequestSchema; output: typeof GetInstallationResponseSchema; }, + /** + * ListInstallations enumerates one organisation's installations with their live + * health. It is internal-tier on purpose: it takes the organisation 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 organisation installed and what that viewer + * was granted. + * + * @generated from rpc saas.accounts.v1.InstallationService.ListInstallations + */ + listInstallations: { + methodKind: "unary"; + input: typeof ListInstallationsRequestSchema; + output: typeof ListInstallationsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_saas_accounts_v1_installations, 0); 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..2b4df3a5c --- /dev/null +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/solution_entitlements_pb.ts @@ -0,0 +1,144 @@ +// @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("CixzYWFzL2FjY291bnRzL3YxL3NvbHV0aW9uX2VudGl0bGVtZW50cy5wcm90bxIQc2Fhcy5hY2NvdW50cy52MSKMAQoTU29sdXRpb25FbnRpdGxlbWVudBIbChNzb2x1dGlvbl9pZGVudGlmaWVyGAEgASgJEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESJAoScm9vdF9zY29wZV9ub2RlX2lkGAMgASgJQgi6SAVyA7ABARIPCgdoZWFsdGh5GAQgASgIIowBCh9MaXN0U29sdXRpb25FbnRpdGxlbWVudHNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoKc3ViamVjdF9pZBgCIAEoCUIIukgFcgOwAQESHQoJcGFnZV9zaXplGAMgASgFQgq6SAcaBRj0AygAEhIKCnBhZ2VfdG9rZW4YBCABKAkieAogTGlzdFNvbHV0aW9uRW50aXRsZW1lbnRzUmVzcG9uc2USOwoMZW50aXRsZW1lbnRzGAEgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Tb2x1dGlvbkVudGl0bGVtZW50EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCULBAQoUY29tLnNhYXMuYWNjb3VudHMudjFCGVNvbHV0aW9uRW50aXRsZW1lbnRzUHJvdG9QAVosYWNjb3VudHMvcGtnL2dlbi9zYWFzL2FjY291bnRzL3YxO2FjY291bnRzdjGiAgNTQViqAhBTYWFzLkFjY291bnRzLlYxygIQU2Fhc1xBY2NvdW50c1xWMeICHFNhYXNcQWNjb3VudHNcVjFcR1BCTWV0YWRhdGHqAhJTYWFzOjpBY2NvdW50czo6VjFiBnByb3RvMw", [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"> & { + /** + * The installed solution's identifier. This is the key a consumer joins + * against its own registered set — for the host's solution registry, the + * registered manifest `id`. + * + * @generated from field: string solution_identifier = 1; + */ + solutionIdentifier: string; + + /** + * @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. + * + * 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 organisation's + * installations through GetInstallation / ListInstallations, not by appearing to + * be entitled to them. + * + * @generated from field: bool healthy = 4; + */ + healthy: boolean; +}; + +/** + * 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 organisation 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..18dc6bb45 --- /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 + * organisation. Internal-tier: it takes both the organisation 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/solutions/SolutionsMenu.tsx b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx index a39c354c2..a91be4a04 100644 --- a/module/services/frontend/code/src/solutions/SolutionsMenu.tsx +++ b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx @@ -3,9 +3,18 @@ import Link from "next/link"; import { useSyncExternalStore } from "react"; +import { authedFetch } 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 @@ -27,21 +36,32 @@ 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 { 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/register", { cache: "no-store", }); 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 non-OK answer keeps the last known list. 503 is an unreadable + // registry or an authority that could not answer; 401 is a session this + // poll could not refresh, which the transport's own recovery handles by + // redirecting. 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(); @@ -96,30 +116,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__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts index 6c6bd290d..00d3c4459 100644 --- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts @@ -13,16 +13,33 @@ const { getEndpoints, getWorkspaceSecret } = vi.hoisted(() => ({ })); vi.mock("codefly", () => ({ getEndpoints, getWorkspaceSecret })); +import type { + SolutionEntitlement, + ViewerEntitlements, +} from "@/solutions/entitlements"; import { browserManifestUrl, + cachedProjection, + entitledSolutions, findSolution, + invalidateProjections, loadSolutions, + navProjection, parseManifest, registerSolution, surfacesProjection, unregisterSolution, } from "@/solutions/registry"; +// 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 = { + id: "audit", + healthy: true, + scopeNodeId: "11111111-1111-1111-1111-111111111111", +}; +const grantedButUnhealthy: SolutionEntitlement = { ...granted, healthy: false }; + function baseManifest(overrides: Record = {}) { return { id: "audit", @@ -381,11 +398,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", @@ -402,14 +420,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", () => { @@ -428,7 +446,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"); @@ -834,13 +852,166 @@ 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", + byId: new Map(solutions.map((entitlement) => [entitlement.id, entitlement])), + revision: solutions.map((s) => `${s.id}:${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"); + return parsed; +} + +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: [ + { id: "ledger", healthy: true, scopeNodeId: "node-ledger" }, + { id: "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, + { id: "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, + { id: "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); }); }); 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..117f513ed --- /dev/null +++ b/module/services/frontend/code/src/solutions/entitlements.ts @@ -0,0 +1,244 @@ +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 registered solution id this entitlement admits. */ + id: 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; + byId: 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" + | "forbidden" + | "unavailable"; + +const GATEWAY_ENTITLEMENTS_PATH = "/solutions/_entitlements"; +const INTERNAL_TOKEN_HEADER = "X-Codefly-Internal-Token"; + +/** + * 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 { + id?: 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.id}\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.id !== "string" || entry.id === "") return null; + if (typeof entry.healthy !== "boolean") return null; + parsed.push({ + id: entry.id, + healthy: entry.healthy, + scopeNodeId: + typeof entry.scopeNodeId === "string" ? entry.scopeNodeId : "", + }); + } + return { + org, + viewer, + byId: new Map(parsed.map((entitlement) => [entitlement.id, 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"; + } + if (response.status === 401) return "unauthenticated"; + // A credential that authenticated but carries no organization, or one the + // authority refused. It is distinct from 401: re-authenticating would not + // change it, so a consumer must not be sent back through login. + if (response.status === 403) return "forbidden"; + 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; +} diff --git a/module/services/frontend/code/src/solutions/registry.ts b/module/services/frontend/code/src/solutions/registry.ts index 40bc1b453..6ef9c2c23 100644 --- a/module/services/frontend/code/src/solutions/registry.ts +++ b/module/services/frontend/code/src/solutions/registry.ts @@ -3,6 +3,10 @@ import "server-only"; import { assertDataGraph, type DataGraph } from "@codefly/saas-plugin-manifest"; import { getEndpoints, getWorkspaceSecret } from "codefly"; +import type { + SolutionEntitlement, + ViewerEntitlements, +} from "@/solutions/entitlements"; import { isStandingConditionMilestone } from "@/solutions/registration-log"; /** @@ -102,7 +106,20 @@ export interface SolutionSurface { } /** The public navigation projection (see navProjection). */ -export type SolutionNav = Pick; +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; @@ -113,6 +130,8 @@ export interface SolutionClientSurfaces { 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[]; } @@ -694,6 +713,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, @@ -704,14 +742,144 @@ export async function findSolution( } /** - * 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. + * 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 solution id: `installations.solution_identifier` on the + * authority side and the registered manifest `id` here. It is the only identifier + * both sides hold — an installation carries no reference to a registration record + * — and SOLUTION_REGISTRATION.md §4 states the correspondence. + * + * 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; + }> = []; + for (const manifest of registered) { + const entitlement = entitlements.byId.get(manifest.id); + if (entitlement === undefined) continue; + pairs.push({ manifest, entitlement }); + } + return pairs; +} + +/** + * 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 write that changes nothing (a heartbeat renewal) 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): SolutionNav { - return { id: manifest.id, nav: { ...manifest.nav } }; +export function navProjection( + manifest: SolutionManifest, + entitlement: SolutionEntitlement, +): SolutionNav { + return { + id: manifest.id, + nav: { ...manifest.nav }, + available: entitlement.healthy, + }; } /** @@ -720,9 +888,10 @@ export function navProjection(manifest: SolutionManifest): SolutionNav { * 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. + * 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. @@ -730,6 +899,7 @@ export function navProjection(manifest: SolutionManifest): SolutionNav { export function surfacesProjection( manifest: SolutionManifest, client: string, + entitlement: SolutionEntitlement, hostOrigin?: string, ): SolutionClientSurfaces | null { const surfaces = (manifest.surfaces ?? []).filter( @@ -752,6 +922,7 @@ export function surfacesProjection( 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 diff --git a/module/tools/interface-docs-gate.mjs b/module/tools/interface-docs-gate.mjs index d29e284eb..a10120040 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/solution_registration_boundary_test.go b/module/tools/solution_registration_boundary_test.go index e1c7d108a..83142b602 100644 --- a/module/tools/solution_registration_boundary_test.go +++ b/module/tools/solution_registration_boundary_test.go @@ -8,29 +8,48 @@ 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. +// The registration/installation boundary is a DECISION, not an accident, and it +// MOVED — partly — with issue #949. // -// 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. +// 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. +// +// What it is now: 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. What did NOT move is route and page exposure: +// `/s/{id}` still renders and `/solutions/{id}/…` still proxies for any caller the +// gateway authenticates, whatever any organization installed. Uninstalling still +// takes no page or route away. +// +// So this file now guards two things rather than one, and the halves pull in +// opposite directions. The surfaces that must STAY installation-blind are scanned +// for coupling as before; the projections 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 +// both halves, and the claim test below keeps the doc and the code together. // 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. +// are SERVED — route and page exposure, which remains deployment-wide. 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. +// +// The projection surfaces are deliberately absent: they are covered by +// TestSolutionProjectionsNarrowByInstallation 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", "services/frontend/code/src/app/(dashboard)/s/[solutionId]/page.tsx", "services/auth-gateway/code/gateway_solutions.go", } +// 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 this issue 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/register/route.ts", + "services/frontend/code/src/app/api/solutions/surfaces/route.ts", +} + // installationCoupling are the identifiers that would signal a registration // surface gating on per-tenant admission. var installationCoupling = []string{ @@ -105,6 +124,10 @@ func TestSolutionRegistrationBoundaryIsDocumented(t *testing.T) { "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", + "`installations.solution_identifier` is the registered manifest `id`", } { if !strings.Contains(document, strings.Join(strings.Fields(claim), " ")) { t.Errorf("SOLUTION_REGISTRATION.md no longer states %q", claim) @@ -112,6 +135,73 @@ 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. Naming both + // means a file cannot pass by importing the authority answer and then + // projecting the unnarrowed set anyway. + 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, + ) + } + } + } +} + +// 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/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 From e0fab25341e43ed785e4ca76f36be2f2f87b5afe Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:08:50 -0400 Subject: [PATCH 2/4] fix: per-viewer projections read the impersonated user, spend the org budget, and never leak across viewers (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the review of this branch (lazybox report r1). Impersonation (f1). The gateway asked accounts about X-User-Id, which during an impersonation is the real actor; accounts authorizes against the acting user. An administrator impersonating someone saw their own grants inside that user's organization, never the menu the user sees. The gateway now asks about the effective subject (X-Acting-As-User-Id when set), which ext_authz stamps after stripping any caller-supplied copy. Unmetered authority load (f4). The entitlement read skipped the gateway's rate limiter and resolved health with three or four queries per installation, on a menu that polls every ten seconds. It now spends the per-org StandardRead budget, and health is one SQL expression inside the listing statement. A parity test holds that expression to GetInstallation's health in every state. A server credential fault read as "sign in again" (f5). The gateway answered 401 for both a refused internal token and a refused user bearer, and the frontend relayed both as unauthenticated — so a rotated internal token would tell every user to sign in and rotate refresh tokens on every poll. The gateway now names its own refusals in X-Codefly-Entitlement-Refusal; the frontend maps the internal-credential case to 503, relays 429, and no longer labels an ext_authz 403 as "no organization". A viewer's menu shown to the next viewer (f7). The menu's list is module state and was never tied to its owner. It is now dropped when the viewer or organization changes, a poll started for a previous viewer is discarded, and an unrecoverable 401 clears it; a routine token refresh changes nothing. Boundary test (f8). registry.ts feeds findSolution, i.e. page exposure, but had left the installation-blind scan. The narrowing moved to projections.ts so registry.ts is scanned again; the presence check's comment no longer claims more than it proves. ListInstallations had no consumer (f9). Both reads now go through one listing query; the entitlement read is that listing narrowed by the grant union. Silent empty menus and an unenforced join key (f2). SOLUTION_REGISTRATION.md §5 now states that every menu is empty after upgrade and how an admin restores a solution. An installation whose identifier can never be a registry id is reported once instead of vanishing silently. Refusing such identifiers at install time would be wrong: an installation also governs agent authority, for which any identifier is valid. The authority answer is not cached on a write-advanced revision (f6): a grant that expires changes the answer with no write, which a new test demonstrates. The docs now say what is and is not cached. available:false is documented as a client contract (f10). Co-Authored-By: Claude Opus 5.5 (1M context) --- module/SOLUTION_REGISTRATION.md | 38 +++ .../code/pkg/infra/postgres_installations.go | 138 +++++++-- .../infra/postgres_solution_entitlements.go | 48 +--- .../postgres_solution_entitlements_test.go | 136 +++++++++ .../code/gateway_solution_entitlements.go | 77 ++++- .../gateway_solution_entitlements_test.go | 100 +++++++ module/services/frontend/AGENTS.md | 39 ++- .../register/__tests__/route.test.ts | 7 +- .../src/app/api/solutions/register/route.ts | 23 +- .../surfaces/__tests__/route.test.ts | 41 ++- .../src/app/api/solutions/surfaces/route.ts | 23 +- .../code/src/solutions/SolutionsMenu.tsx | 118 ++++++-- .../__tests__/SolutionsMenu.test.tsx | 158 +++++++++++ .../src/solutions/__tests__/registry.test.ts | 58 +++- .../code/src/solutions/entitlements.ts | 64 ++++- .../code/src/solutions/projections.ts | 267 ++++++++++++++++++ .../frontend/code/src/solutions/registry.ts | 223 +-------------- .../solution_registration_boundary_test.go | 15 +- 18 files changed, 1219 insertions(+), 354 deletions(-) create mode 100644 module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx create mode 100644 module/services/frontend/code/src/solutions/projections.ts diff --git a/module/SOLUTION_REGISTRATION.md b/module/SOLUTION_REGISTRATION.md index 58c91e57a..50ab84c9c 100644 --- a/module/SOLUTION_REGISTRATION.md +++ b/module/SOLUTION_REGISTRATION.md @@ -263,3 +263,41 @@ 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. + +### Upgrading to per-viewer projections (issue #949) + +**Every Solutions menu and every client's surface list is empty immediately +after this upgrade, for every user, admins included.** That is the consequence of +§4's rules, not a fault: before this contract a *registered* solution was listed +for everyone, and now a solution is listed only when the viewer's organization has +an active installation of it **and** one of the viewer's teams (or the viewer) holds +a grant that permits `(solution, use)` at or above that installation's scope node. +Registration never created installations, `(solution, use)` is a permission no role +carried before, and there are no implicit grants — so at upgrade no viewer +satisfies both. Registration, pages and the solution proxy are unchanged: a solution +still renders at `/s/{id}` for anyone the gateway authenticates. + +To restore a solution for an organization, an org admin: + +1. installs it with `InstallationService/InstallSolution`, passing the solution's + **registered id** as `solution_identifier` (see below); +2. creates a role permitting `solution:use` (or grants a role that already carries + `*:*`) with `PermissionService/CreateRole`; +3. grants that role with `PermissionService/GrantScope` to each team that should + see it, at the installation's authority-root scope node — or at the + organization root to give it to every team at once, which the scope tree's + ancestor rule then carries down to every solution node. + +The host ships no migration that writes these grants: the owner's decision is that +a solution reaches no team until a grant is written, and a backfill that granted +every registered solution to every organization would be exactly the implicit +grant that decision rules out. + +**The solution identifier must be the registered id.** An installation's +`solution_identifier` is free text, because an installation also governs agent +authority and any identifier serves that purpose; the projections, though, match it +against the registered manifest `id`, a lowercase slug. An installation under any +other identifier (`acme.example/solution`, say) is valid for agent authority and can +never appear in a menu. The frontend reports each such identifier once, in its log +(`solution projections: an installation's solution_identifier … is not a +registered-solution id shape`), so the mismatch is visible rather than silent. diff --git a/module/services/accounts/code/pkg/infra/postgres_installations.go b/module/services/accounts/code/pkg/infra/postgres_installations.go index bcf91ba2c..8c0818183 100644 --- a/module/services/accounts/code/pkg/infra/postgres_installations.go +++ b/module/services/accounts/code/pkg/infra/postgres_installations.go @@ -371,43 +371,125 @@ func (s *PostgresStore) ListInstallations( cursor = parsed.String() } - rows, err := executor.Query(ctx, - `SELECT `+installationColumns+` - FROM installations - WHERE org_id = $1 - AND ($2 = '' OR status = $2) - AND ($3 = '' OR id::text > $3) - ORDER BY id - LIMIT $4`, - orgID, installationStatusColumn(status), cursor, limit) + summaries, err := s.listInstallationSummaries(ctx, executor, orgID, + installationStatusColumn(status), cursor, limit, "TRUE") if err != nil { return nil, w.Wrapf(err, "failed to list installations") } - defer rows.Close() + return summaries, nil +} - var installations []*gen.Installation - for rows.Next() { - installation, e := scanInstallation(rows) - if e != nil { - return nil, w.Wrapf(e, "failed to scan installation") - } - installations = append(installations, installation) +// 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 } - if e := rows.Err(); e != nil { - return nil, w.Wrapf(e, "failed to read installations") +} + +// 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.solution_identifier, 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, ` + 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, solution_identifier) 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 } - // Health resolution runs after the cursor is exhausted, never inside the - // loop: it issues its own queries on the same connection, and pgx will not - // serve those while rows from this query are still being read. - summaries := make([]*gen.InstallationSummary, 0, len(installations)) - for _, installation := range installations { - health, e := s.resolveInstallationHealth(ctx, executor, installation) + 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, w.Wrapf(e, "failed to resolve installation health") + return nil, e } - summaries = append(summaries, &gen.InstallationSummary{Installation: installation, Health: health}) + summaries = append(summaries, &gen.InstallationSummary{ + Installation: installation, + Health: installationHealthFromLabel(label), + }) } - return summaries, nil + return summaries, rows.Err() } // GetInstallation loads one installation and resolves its health live from the diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go index bf0b1ee56..ee9b1a0bc 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go @@ -49,8 +49,8 @@ func (s *PostgresStore) ListSolutionEntitlements( // $1 subject, $2 resource_type, $3 action, $4 org, $5 the node kind this // narrows to, $6 platform admissibility — the parameter contract - // accessibleScopesQuery documents — then $7 cursor and $8 limit, appended by - // this query. + // 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 @@ -67,51 +67,29 @@ func (s *PostgresStore) ListSolutionEntitlements( // 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. - rows, err := executor.Query(ctx, - `SELECT `+installationColumns+` - FROM installations - WHERE org_id = $4 - AND status = 'active' - AND root_scope_node_id::text IN (`+admitted+`) - AND ($7 = '' OR id::text > $7) - ORDER BY id - LIMIT $8`, + // + // 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), - cursor, limit) + platformReadAdmissible(ctx, gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, business.ActionUseSolution)) if err != nil { return nil, w.Wrapf(err, "failed to list solution entitlements") } - defer rows.Close() - - var installations []*gen.Installation - for rows.Next() { - installation, e := scanInstallation(rows) - if e != nil { - return nil, w.Wrapf(e, "failed to scan entitled installation") - } - installations = append(installations, installation) - } - if e := rows.Err(); e != nil { - return nil, w.Wrapf(e, "failed to read solution entitlements") - } - // Health resolution issues its own queries, so it runs after this cursor is - // drained rather than inside the loop. - out := make([]*gen.SolutionEntitlement, 0, len(installations)) - for _, installation := range installations { - health, e := s.resolveInstallationHealth(ctx, executor, installation) - if e != nil { - return nil, w.Wrapf(e, "failed to resolve entitled installation health") - } + out := make([]*gen.SolutionEntitlement, 0, len(summaries)) + for _, summary := range summaries { + installation := summary.GetInstallation() out = append(out, &gen.SolutionEntitlement{ SolutionIdentifier: installation.GetSolutionIdentifier(), 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: health == gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, + Healthy: summary.GetHealth() == gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, }) } return out, 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 index 0f5a52ef6..48552c3a3 100644 --- a/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go @@ -10,6 +10,7 @@ import ( "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" + "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) @@ -403,3 +404,138 @@ func TestListInstallations_RejectsAForeignCursor(t *testing.T) { 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). +func controlPlaneExec(t *testing.T, sql string, args ...any) { + t.Helper() + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + tx := ctx.Value("tx").(pgx.Tx) //nolint:staticcheck // shared key with WithControlPlane + _, err := tx.Exec(ctx, sql, args...) + return err + })) +} + +// 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"}, 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/auth-gateway/code/gateway_solution_entitlements.go b/module/services/auth-gateway/code/gateway_solution_entitlements.go index c19881ffb..5245dea64 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -54,6 +54,23 @@ func (g *Gateway) handleSolutionViewerSegment(w http.ResponseWriter, r *http.Req const listSolutionEntitlementsMethod = "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" +// solutionEntitlementRefusalHeader names which of this endpoint's OWN 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). +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" +) + // 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 @@ -144,6 +161,7 @@ func (g *Gateway) handleSolutionEntitlements(w http.ResponseWriter, r *http.Requ 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 } @@ -181,27 +199,66 @@ func (g *Gateway) handleSolutionEntitlements(w http.ResponseWriter, r *http.Requ // 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 := r.Header.Get("X-User-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 } - 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 + // 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, } - writeSolutionJSON(w, http.StatusOK, solutionEntitlementProjection{ - Org: org, Viewer: viewer, Usable: entries, + 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 diff --git a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go index 50e563f75..2c560b891 100644 --- a/module/services/auth-gateway/code/gateway_solution_entitlements_test.go +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -8,9 +8,12 @@ import ( "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" ) @@ -236,3 +239,100 @@ func TestGatewaySolutionEntitlements_EmptySetIsAnAnswer(t *testing.T) { 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/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index 79663f55a..46d825d8f 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -74,12 +74,39 @@ 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. -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 simply is not routed as serving. The projection cache is -keyed on organization, viewer, a digest of the entitlement answer, and client kind, -so a grant or a revoke moves the key and a cached menu cannot outlive the grant -that justified it. +**`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. Only the listing is narrowed: `/s/{id}` and the solution proxy still render +and route a registered solution for any authenticated caller, available or not +(route and page exposure stays deployment-wide, SOLUTION_REGISTRATION.md §4). + +**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/src/app/api/solutions/register/__tests__/route.test.ts b/module/services/frontend/code/src/app/api/solutions/register/__tests__/route.test.ts index d24a85659..0b947687c 100644 --- 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 @@ -15,11 +15,8 @@ const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({ vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints })); import { DELETE, GET, POST } from "@/app/api/solutions/register/route"; -import { - findSolution, - navProjection, - type SolutionManifest, -} from "@/solutions/registry"; +import { navProjection } from "@/solutions/projections"; +import { findSolution, type SolutionManifest } from "@/solutions/registry"; const TOKEN = "internal-test-token"; const { publicKey, privateKey } = generateKeyPairSync("ed25519"); 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 index d025f497e..c0055987c 100644 --- a/module/services/frontend/code/src/app/api/solutions/register/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/register/route.ts @@ -12,12 +12,18 @@ import { observeRegistrationRemoved, type RegistrationRefusal, } from "@/solutions/registration-log"; -import { viewerEntitlements } from "@/solutions/entitlements"; +import { + entitlementFailureResponse, + isEntitlementFailure, + viewerEntitlements, +} from "@/solutions/entitlements"; import { cachedProjection, entitledSolutions, - loadSolutionsWithRevision, navProjection, +} from "@/solutions/projections"; +import { + loadSolutionsWithRevision, parseManifest, registerSolution, type SolutionWriteResult, @@ -308,17 +314,8 @@ export async function DELETE(request: Request): Promise { // indistinguishable from an organization that installed nothing. export async function GET(request: Request): Promise { const entitlements = await viewerEntitlements(request); - if (entitlements === "unauthenticated") { - return Response.json({ error: "unauthenticated" }, { status: 401 }); - } - if (entitlements === "forbidden") { - return Response.json({ error: "no_organization" }, { status: 403 }); - } - if (entitlements === "unavailable") { - // The authority could not answer. Never an empty list: the menu holds its - // last known set rather than emptying on an authority blip, exactly as it - // does for an unreadable registry. - return Response.json({ error: "authority_unavailable" }, { status: 503 }); + if (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); } const registered = await loadSolutionsWithRevision(); // An empty registry and an unreadable one must not render the same: the 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 42792cf90..b9103eb89 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 @@ -40,6 +40,8 @@ interface EntitlementAnswer { 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; } @@ -58,8 +60,13 @@ function gatewayServing( 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 @@ -360,12 +367,44 @@ describe("solutions surfaces route", () => { }); it("answers 403 when the session carries no organization", async () => { - vi.stubGlobal("fetch", gatewayServing([audit], { status: 403 })); + 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")); 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 8f2699e8a..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,11 +1,17 @@ import { requestPublicOrigin } from "@/lib/public-origin"; -import { viewerEntitlements } from "@/solutions/entitlements"; +import { + entitlementFailureResponse, + isEntitlementFailure, + viewerEntitlements, +} from "@/solutions/entitlements"; import { cachedProjection, entitledSolutions, + surfacesProjection, +} from "@/solutions/projections"; +import { isClientKind, loadSolutionsWithRevision, - surfacesProjection, type SolutionClientSurfaces, } from "@/solutions/registry"; @@ -56,17 +62,8 @@ export async function GET(request: Request): Promise { // 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 (entitlements === "unauthenticated") { - return Response.json({ error: "unauthenticated" }, { status: 401 }); - } - if (entitlements === "forbidden") { - return Response.json({ error: "no_organization" }, { status: 403 }); - } - if (entitlements === "unavailable") { - // The authority could not answer. Distinct from an empty entitlement set - // for the same reason an unreadable registry is distinct from an empty one: - // answering [] would silently retract every surface a client is showing. - return Response.json({ error: "authority_unavailable" }, { status: 503 }); + if (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); } const registered = await loadSolutionsWithRevision(); // An empty registry and an unreadable one must not render the same: the diff --git a/module/services/frontend/code/src/solutions/SolutionsMenu.tsx b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx index a91be4a04..df5f78ada 100644 --- a/module/services/frontend/code/src/solutions/SolutionsMenu.tsx +++ b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx @@ -3,7 +3,12 @@ import Link from "next/link"; import { useSyncExternalStore } from "react"; -import { authedFetch } from "@/lib/connect/token-store"; +import { decodeJWTPayload } from "@/lib/auth-session"; +import { + authedFetch, + getToken, + subscribeToken, +} from "@/lib/connect/token-store"; interface SolutionNav { id: string; @@ -27,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; @@ -46,6 +107,9 @@ function sameList(a: SolutionNav[], b: SolutionNav[]): boolean { } async function refresh(): Promise { + forgetIfViewerChanged(); + const viewer = snapshotViewer; + if (viewer === null) return; try { // 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 @@ -55,25 +119,26 @@ async function refresh(): Promise { const response = await authedFetch("/api/solutions/register", { 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) { - // Every non-OK answer keeps the last known list. 503 is an unreadable - // registry or an authority that could not answer; 401 is a session this - // poll could not refresh, which the transport's own recovery handles by - // redirecting. 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. + // 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. } @@ -84,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; } /** 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__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts index 00d3c4459..956cb7753 100644 --- a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts @@ -18,16 +18,18 @@ import type { ViewerEntitlements, } from "@/solutions/entitlements"; import { - browserManifestUrl, cachedProjection, entitledSolutions, - findSolution, invalidateProjections, - loadSolutions, navProjection, + surfacesProjection, +} from "@/solutions/projections"; +import { + browserManifestUrl, + findSolution, + loadSolutions, parseManifest, registerSolution, - surfacesProjection, unregisterSolution, } from "@/solutions/registry"; @@ -1015,3 +1017,51 @@ describe("cachedProjection", () => { expect(compute).toHaveBeenCalledTimes(2); }); }); + +describe("an entitlement that can never join a registration", () => { + beforeEach(() => { + (globalThis as Record).__unjoinableSolutionIdentifiers = + undefined; + }); + + it("is reported once, instead of the solution silently never appearing", () => { + // An installation's solution_identifier is free text; a registered id is a + // slug. "acme.example/solution" can never match, so without a report the + // solution is installed, granted, and invisible with no error anywhere. + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const unjoinable = entitlements({ + solutions: [ + granted, + { id: "acme.example/solution", healthy: true, scopeNodeId: "node-x" }, + ], + }); + entitledSolutions([manifestFor("audit")], unjoinable); + entitledSolutions([manifestFor("audit")], unjoinable); + const reports = errors.mock.calls.filter((call) => + String(call[0]).includes("acme.example/solution"), + ); + expect(reports).toHaveLength(1); + } finally { + errors.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, + { id: "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 index 117f513ed..a43ad9e19 100644 --- a/module/services/frontend/code/src/solutions/entitlements.ts +++ b/module/services/frontend/code/src/solutions/entitlements.ts @@ -73,12 +73,26 @@ export interface ViewerEntitlements { */ 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 @@ -226,11 +240,25 @@ export async function viewerEntitlements( 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"; - // A credential that authenticated but carries no organization, or one the - // authority refused. It is distinct from 401: re-authenticating would not - // change it, so a consumer must not be sent back through login. + // 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"; @@ -242,3 +270,33 @@ export async function viewerEntitlements( } 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..743fa87bf --- /dev/null +++ b/module/services/frontend/code/src/solutions/projections.ts @@ -0,0 +1,267 @@ +import "server-only"; + +import type { + SolutionEntitlement, + ViewerEntitlements, +} from "@/solutions/entitlements"; +import { + isSolutionId, + 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 solution id: `installations.solution_identifier` on the + * authority side and the registered manifest `id` here. It is the only identifier + * both sides hold — an installation carries no reference to a registration record + * — and SOLUTION_REGISTRATION.md §4 states the correspondence. + * + * 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; + }> = []; + for (const manifest of registered) { + const entitlement = entitlements.byId.get(manifest.id); + if (entitlement === undefined) continue; + pairs.push({ manifest, entitlement }); + } + for (const id of entitlements.byId.keys()) { + if (!isSolutionId(id)) reportUnjoinableEntitlement(id); + } + 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. + * + * The join key is the installation's solution_identifier against the registered + * manifest id, and nothing at install time requires the two to agree: an + * installation also governs agent authority, for which any identifier works, so + * refusing a non-slug one there would break installs that are valid for that + * purpose. What goes wrong is only this projection — the solution is installed and + * granted and still never appears, with no error anywhere. An identifier that is + * slug-shaped but unregistered is NOT reported: that is a solution this deployment + * does not serve right now, which is ordinary. + */ +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.error( + `solution projections: an installation's solution_identifier ${JSON.stringify(id)} is not a registered-solution id shape, so it can never appear in any projection. Install it under the solution's registered id.`, + ); +} + +/** + * 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 write that changes nothing (a heartbeat renewal) 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/registry.ts b/module/services/frontend/code/src/solutions/registry.ts index 6ef9c2c23..bd4cd7696 100644 --- a/module/services/frontend/code/src/solutions/registry.ts +++ b/module/services/frontend/code/src/solutions/registry.ts @@ -3,10 +3,6 @@ import "server-only"; import { assertDataGraph, type DataGraph } from "@codefly/saas-plugin-manifest"; import { getEndpoints, getWorkspaceSecret } from "codefly"; -import type { - SolutionEntitlement, - ViewerEntitlements, -} from "@/solutions/entitlements"; import { isStandingConditionMilestone } from "@/solutions/registration-log"; /** @@ -105,7 +101,7 @@ export interface SolutionSurface { events?: string[]; } -/** The public navigation projection (see navProjection). */ +/** 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 @@ -124,7 +120,7 @@ export type SolutionNav = Pick & { /** The internal detail projection (see detailProjection). */ export type SolutionDetail = Omit; -/** The per-client surface projection (see surfacesProjection). */ +/** The per-client surface projection (see surfacesProjection in projections.ts). */ export interface SolutionClientSurfaces { id: string; title: string; @@ -151,6 +147,15 @@ 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 @@ -741,212 +746,6 @@ export async function findSolution( return current.byId.get(id) ?? null; } -/** - * 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 solution id: `installations.solution_identifier` on the - * authority side and the registered manifest `id` here. It is the only identifier - * both sides hold — an installation carries no reference to a registration record - * — and SOLUTION_REGISTRATION.md §4 states the correspondence. - * - * 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; - }> = []; - for (const manifest of registered) { - const entitlement = entitlements.byId.get(manifest.id); - if (entitlement === undefined) continue; - pairs.push({ manifest, entitlement }); - } - return pairs; -} - -/** - * 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 write that changes nothing (a heartbeat renewal) 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], - })), - }; -} - /** * 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 diff --git a/module/tools/solution_registration_boundary_test.go b/module/tools/solution_registration_boundary_test.go index 83142b602..b90d872f5 100644 --- a/module/tools/solution_registration_boundary_test.go +++ b/module/tools/solution_registration_boundary_test.go @@ -34,9 +34,12 @@ import ( // 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. // -// The projection surfaces are deliberately absent: they are covered by +// registry.ts is here because its findSolution is what `/s/{id}` renders from. +// The per-viewer narrowing lives in projections.ts precisely so this file can +// stay in the scan; the projection routes are covered by // TestSolutionProjectionsNarrowByInstallation below, which requires the opposite. var registrationSurfaces = []string{ + "services/frontend/code/src/solutions/registry.ts", "services/frontend/code/src/app/(dashboard)/s/[solutionId]/page.tsx", "services/auth-gateway/code/gateway_solutions.go", } @@ -150,9 +153,12 @@ func TestSolutionProjectionsNarrowByInstallation(t *testing.T) { t.Fatalf("read %s: %v", relative, err) } code := codeOnly(string(data)) - // The entitlement read, and the narrowing that consumes it. Naming both - // means a file cannot pass by importing the authority answer and then - // projecting the unnarrowed set anyway. + // 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", @@ -179,6 +185,7 @@ 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 { From 638dcb6261acf317fd2d06046fdeeb63bc88aefc Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:15:21 -0400 Subject: [PATCH 3/4] chore: regenerate contract digests after rebasing onto main (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto e402be0d3 conflicted only in generated contract digests: #950 and this branch both changed the accounts contract, and a sha256 cannot be merged. The conflicted files were resolved to main's bytes and then regenerated from source rather than hand-merged — `codefly generate contracts saas-starter` on CLI 0.1.168 (the version scripts/ci/install-codefly.sh pins) and the saas-sdk library regeneration. `codefly generate proto` and the catalog generators ran clean with no changes, and `module-package run-generators` added nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 923076 -> 932993 bytes .../api/accounts/connect/contract.binpb | Bin 923076 -> 932993 bytes module/contracts/api/catalog.codefly.json | 22 +++++++++++++++--- module/module.package.codefly.yaml | 6 ++--- .../generated/contract/catalog.codefly.json | 10 +++++++- .../generated/contract/contract.binpb | Bin 923076 -> 932993 bytes .../saas-sdk/generated/library.codefly.yaml | 3 ++- 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 2560eb356bc3537aa4adfe8c85649613522cf502..af91abb8cf85ef9acf49064ae46f572d72437ed4 100644 GIT binary patch delta 9660 zcmbtadvG1qdDl6+ckjJ>Ww5Vb;@Vh8!eAL2`2i)^7^q?iEMu@@Lz=V=_UdXSExy`S zcJH+;{#j_;5E2@Kd29@XR~l&Jj!haG#gNjWlNL%tUNbFC=}b!cA|Zu#8q(02&h+=4 zb9V1un|3zZYuAvEsUD2C^@yNSWEtBsN9|0w!M7r`RnZR ztTW{vh`ig}9z(E!!Uyf=b`@)do}U>5$sPYzsU~~qo#18Ly?%9cA6saT@i#+j_=(jr-*4M z68a+U#Gn{C?1u9qXimGKE%uG=!~)Tr_979v;bE`t3g4M=BjJcB_WY@ao353cZ4^-; zT7J`Os5R~psLY)ZKK8?GThxPAV?sE7H0Oq*Uou4J%LBkwx|ut1O~3fj?e~LauRbks zI`|Pzt2rHnUUOcAt}`KG#}>10cuB`?qM*?tk&D?dxYe!8Ke2qg&4>=lnN)2`Yj=#oR12GLeN!VIlnNdwOh zVOE}mtsZ7(x8B*nj7Q0!J|;DekB_qC!Y8edfC&RkJj%+c{TTA&ik0@u zig7oB?u6Hb6*zU$r4uE6ljWtcoP{9Kl%|3c2gNSEM>BK|yA6aE5Y zg}eJvYbLeC(Df(W5LO2RQIQj8XAbK`HbRGU*zp?9IE-Ey76M1?hI1aa^Ic4*`(Ed! zX+T9V1c7uzNzi^>glxaWO3K!XybGT96yxVezs69Gl+$B`xlgfkv$@d2Q>k6XMNVG( zG1PtXUT}w9oG}AVXk68OOt?h;iVMSkNU8fk6k*0*1UuLB~X&5EmWme4-jN5Wo z4CQD``VmK-V3jrYq7TC$@fB-Nu-=X7XPk0^ZQ87d3^sO>Y-}0~mWV-&&aeHd*zYzG zMB$P%L6|wdlv47q|6y*u!WIn&@qx0Xc2caC%ga6K-Z<(cTeaGLnc3Xxs#?#ouIH1R zK30Bd>9ZtWq#@Jl0{hl!#=o~PcuV;z7N2I;a#~Lxc-1`3R!Giii0w|Z59}mIqoG-~ zo@3^>m_?2z319y#9PK$)`4%gXSEV#?^*pmK%9F73H<)#?z09`LSq1R%hb*>UWX1eU zNb`@G)oU-cE9uN4gT%kdN*i!+w!DZf|AdtW@@o;)Ut-o}c~ZG*zRavGd6Hc<|BP9e zDpHJl;Tn~y`N|n9yQ}7()3o;OUuEWZar>qErq8_cjsB|jDyw`K*H1YkBCO@xbcH;v zST(=HtaI(JDIKt?^%~>vFk|7(gXK$E{2H@XYArBa>^0US4F(b*D}9Y!Ii~qw(86Ca z^Cy`W7_m>Z%1<&aNE)>8gSHkV2|NFFTMLpzT6i5=_+h36Nro2wwygz8l3MstTMLSa zE&r~q1xbSz{-G^PNrD#sv8@G3f)@TMrv*ua7T!E#CA9G4wif92Z!z=jObg6ZT6l|9 z-p;h3h|t1MnRTVsf}%kS|4drATWJAl>s`iwiyI4{ub0a#ewSGnXf1GD>|M5YS2~0i z-evm_Xf1H?@pEQ=il@H7l@@-^Dxc!1FGw1+@b7IcND_AbMOzD!L|S-{nbv=@V!~c3 zEKC04U}155J1=e1?8{kX@^|pepN+dvUq+oE_oL>@5UvS$#X62yLjbp+)|mhB0|`o=GM**8if5B4q^WbY6KWEjPw}i zzn!d}EWZ;U<5|=(aI0fHi#n89kMR%OsJSq}#C_a+GLy8SBz+&RJef&a(je*kc@}jf z2|GW)tqnTrND@i(qcjOh{(_Y*Wt1z%j${318^WE)At2B~trmZ~Ae53at*#cum6rj#ng zbCUDtxKVrDJiDt9RM@$w5YY(a`-zx}lXY1;A=6B|Ht<4OXxKs(AcJDGNfnIeH{H;8 z8kaOZ zm94bCj`9ZwU6LLr>YqsZ zyRo-U&CX;P%zy`!njSdAhEx(ascoT-M%JKGYK|C3wS=}xM6FrW-PCko{Lrs zI_Z#KyZpk9RbMDiP_u0@2LVrokN|j!W$g}6GvNv+d6rO2n9NC@B@`15c9LItlXfr@ z7Icc6r!xmLm4lt)mD8DnNg8&2hFg8wKqUz~e}!8YDpJfP4X*V2ZC8>ca;2}fT}jg5 zO3$`ELXzN0e?ac4{8EzON?&Wck|e>E7S31*Px?By*rj*wo-X6Z#!qEo_jLCfZhrqF zdt=hLwYyf4s%^M4_?rNjQOGP{>r z|Mb-4nfEG}B|qI}UHrdALKZ*3Z0YCO7Vo@QS$@u_-JQSRp5aBSlD!XhmzL-5wJ)2J zyl_upqrLk(eKwTU)%Bs|j#{}de!YS^7?oCU*EUwWpNsT_+!kpg(>BXgQ1GLuHBODZ zbW(H`I&9czLmS?>3_{Z7bN#;Vh170wLx!P^gh5ER)rUyxc0tW@+N8*Jh)BRUB5F3X zegzc2C8LAfA(-|{NHQCGe%+fzupzJY z16150014{aoaUgtb)lSme4wy59z%#yUfAx8aw_P+FKk;3X$Ke4wCcmr=hLaF{ABDz z;Qb%GAJZ|R={hrLz+waoH+6k=V~yPiw0RKzkc=)Wr~+J|o(yMDFv`H0Npz3V8G&hg z8f4B0F1J}DYLrN#^-_om0tzN6Fe5E=bb8Uc@uKu#q8mQQNZ$O*X`tf}rv-G!u0zD8zCI1`x|bWh>~R)Dy}-HGI#x$gDJ>?XT^nM`w1 z=nXb;v&Xw@rHKIBA_*pMn8oGE?{2cYnVsBnq0nPGOAQLM8?t%E2MR> z>`Xbd3u+1^<2fI4new6-B?*Bb2pCixs8sI?sIG|v(p##zP^u0!a;aJgs0%P5wms-g z9E7RryD)HHUY(8%jCzG00V*)o;+_?{W(=qRqwCUdG(zLAc0rG5tVQ3iM5Lf%l@efh|F!N=guRPWkjz+Tx(t>4YJQDM&A`1;YO#2e6>B3JD|v>KD$$OzbtO zZbPa`!DDTXL_&y1ByR-4q1J3BUy|BDIkg=0AjW_Me8`S~92x^+RLhAVLO{UAK;JGc zC%}QmR+L4r7NwnG?f6!{wDtv4{K~X0;vj&0mLj{-nO}t7)HK3wFo)pM0pH20lSD^O z2W#sL#|p@s4Z>zb@oWe{YS8EiPh_yO0RAUCkVb{`Q5e-#IWj=ovQ0 zeFWH);Y_AZ6nEfzlps|xtn;j@yk)rXrA1&Lgk}Uo6u=a`$h#1CWU;5L$*=<=nta-H8@5 zJT(QR9Wb zA0!}&VL4<2}|&=Ze45vIz%M+Zxp#8g^?MN^ES*O7+PgtN?0gW-k; z=!2id7?q>ML6Yo26&zJwXhkHyoHr=0U{g{HXtd%$U346|4*r-y>tq>t!WeHMML>CZ z_mnyyB{_<15+|7>9R-0Zuoub!p9pDKiK1@OQ6)zQ`*_KKA(3h19U z#hcFVW>skjO**r)u7f%aNj$L0QuGC6=z*GNQvZ($w>P#H2}>rI2e@EtT>{_&l`h(u zE4y|x{B4*3QjIH1>*OpZ6U+`BcrmIPJNVViv?DN)?T zH;N}Q0?H0NDX9q*(1)<^LqMM}16vL857zGHDvRpsSycl$jX*?*m}wtRZk?5=uu@qM zl2kiZsPOBEc*dPU=?x>0C6aUjDj-$im?mCw47}F3VPhULC2*gLuCxwG>n!zT6&;c` zC);eN%?b2OFNKllm$jPo9Gn(Y2>?q$H=MQw{m?&Jd|4&w)>m=_i=4zntvN`C#Dn)1 zEjmbg2+u8g1W6C|3~5QH8E)vjU)J9#!wm!Ad9aUC93w1mm|yq4k5kFpk6Ww%Z*Nl< z$BG+LLr=uA8+tdSBW!-dAZkLn$!4p62cfrgtyk~s$ zjQ5P3b;{?A%R8Sl)K;YOF9$s+Mm`_qf;%VZ~CN;gXmv*LwJwfAk} zGpxA&D%6Ix{b#rK`_?1#|J4 z3loPI*OgD=d4d}1waLA_*c)GNmu(q<3SAf3_gnH!#$4L9mepP`-mkmX+-1I${To28 zSEA=S1W~0P4Kdyc$2~MuDATgwglB&O>Hc3ppURoJM{=ME)S7@D3R=WZ3hW^{ILMj` zLzJjs-0tnQ0C5qGKA?(J=%1azfA5F>$#Mf%KN|qFxBjV$+lX%e4GvIHSkso{-RBwj zFNWllS-fyVT4i}26cx7ba0jQ~38a!NNP3R}$IU&}-bybhESU(V<9+}gcx;MPU%iT?tSl@S&I delta 1611 zcmZA1TWm~07zgnE&zYUwGiMRgx+D?=71Tq8q(vm85AGrno+SE2B9T7zdE4Y9Jc#H+ zEfGT8S|yRR90?(4m3ms22qCVeUV8E1UVJm>?98SQ`!N5x?0mm(cUped^>o%XhX`5d zg*;4$BFu!@Zhv+iyV6joY4WE;wRQgSOmuIJAA${wXnnok=mi^^*UF4ar&2Af(0U-) z935NhFPxrx0Les3iNi>>6vlS@eHDk1ogr^A$wi9ML>t)U==mOh74mHmQ{)7h))P$e zHcVYy8hUzITQ4WYvs{TI;O!7n$*f+^uOISs7PKQRCriJSLGc2_{PKuSJ_@l&R!Uh~ zm8N?fLtdOfrhKJP{3OIISs{bc$_!G9pF*~pzSwTh(2=K+T~NL^tN0m+W#y5t_*sZ0 z<&jkU9K_N%%Ex5{ik~01GpG0kz06hL0e*?9uShDr(tmS~5*-LGQTe5t>5waaQI}Aj zWfbp(m?W>oJ%AEjV2|KMt$Y2Y$ag_ZOV~k$bzyoi-Z^VXGy#9qX?YFe&xrknWj8Ugzbq6R(v|sF$SzL`F*BO|wPQCKvA=P8CL;E?j@?AW{?4(R zh}hqc+eqvm96Q@i^$vg!T6UJO4~To!pCIb(ZFPT${j+8tfq8Q1nHU0VF7!6~ z!}%fi;&@cmkb7}FLLWlymIU3SOALemwdh`qK8)b6MK>9tk2rJ_(aEC_)ya!vB8~o) zUYu_h-Nc0c-Thrnq_uxwVs-Ltn2fZ4Iy4iJ_AiK<@>Qh$+i9Csr2VIC;~HHAagXU` z@xx1n@_*o)>_O%yPEoKiQS}Tz-Rd7^Z`fbeZfs@7ao)jDBk=YJ7M B2+sfj diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 2560eb356bc3537aa4adfe8c85649613522cf502..af91abb8cf85ef9acf49064ae46f572d72437ed4 100644 GIT binary patch delta 9660 zcmbtadvG1qdDl6+ckjJ>Ww5Vb;@Vh8!eAL2`2i)^7^q?iEMu@@Lz=V=_UdXSExy`S zcJH+;{#j_;5E2@Kd29@XR~l&Jj!haG#gNjWlNL%tUNbFC=}b!cA|Zu#8q(02&h+=4 zb9V1un|3zZYuAvEsUD2C^@yNSWEtBsN9|0w!M7r`RnZR ztTW{vh`ig}9z(E!!Uyf=b`@)do}U>5$sPYzsU~~qo#18Ly?%9cA6saT@i#+j_=(jr-*4M z68a+U#Gn{C?1u9qXimGKE%uG=!~)Tr_979v;bE`t3g4M=BjJcB_WY@ao353cZ4^-; zT7J`Os5R~psLY)ZKK8?GThxPAV?sE7H0Oq*Uou4J%LBkwx|ut1O~3fj?e~LauRbks zI`|Pzt2rHnUUOcAt}`KG#}>10cuB`?qM*?tk&D?dxYe!8Ke2qg&4>=lnN)2`Yj=#oR12GLeN!VIlnNdwOh zVOE}mtsZ7(x8B*nj7Q0!J|;DekB_qC!Y8edfC&RkJj%+c{TTA&ik0@u zig7oB?u6Hb6*zU$r4uE6ljWtcoP{9Kl%|3c2gNSEM>BK|yA6aE5Y zg}eJvYbLeC(Df(W5LO2RQIQj8XAbK`HbRGU*zp?9IE-Ey76M1?hI1aa^Ic4*`(Ed! zX+T9V1c7uzNzi^>glxaWO3K!XybGT96yxVezs69Gl+$B`xlgfkv$@d2Q>k6XMNVG( zG1PtXUT}w9oG}AVXk68OOt?h;iVMSkNU8fk6k*0*1UuLB~X&5EmWme4-jN5Wo z4CQD``VmK-V3jrYq7TC$@fB-Nu-=X7XPk0^ZQ87d3^sO>Y-}0~mWV-&&aeHd*zYzG zMB$P%L6|wdlv47q|6y*u!WIn&@qx0Xc2caC%ga6K-Z<(cTeaGLnc3Xxs#?#ouIH1R zK30Bd>9ZtWq#@Jl0{hl!#=o~PcuV;z7N2I;a#~Lxc-1`3R!Giii0w|Z59}mIqoG-~ zo@3^>m_?2z319y#9PK$)`4%gXSEV#?^*pmK%9F73H<)#?z09`LSq1R%hb*>UWX1eU zNb`@G)oU-cE9uN4gT%kdN*i!+w!DZf|AdtW@@o;)Ut-o}c~ZG*zRavGd6Hc<|BP9e zDpHJl;Tn~y`N|n9yQ}7()3o;OUuEWZar>qErq8_cjsB|jDyw`K*H1YkBCO@xbcH;v zST(=HtaI(JDIKt?^%~>vFk|7(gXK$E{2H@XYArBa>^0US4F(b*D}9Y!Ii~qw(86Ca z^Cy`W7_m>Z%1<&aNE)>8gSHkV2|NFFTMLpzT6i5=_+h36Nro2wwygz8l3MstTMLSa zE&r~q1xbSz{-G^PNrD#sv8@G3f)@TMrv*ua7T!E#CA9G4wif92Z!z=jObg6ZT6l|9 z-p;h3h|t1MnRTVsf}%kS|4drATWJAl>s`iwiyI4{ub0a#ewSGnXf1GD>|M5YS2~0i z-evm_Xf1H?@pEQ=il@H7l@@-^Dxc!1FGw1+@b7IcND_AbMOzD!L|S-{nbv=@V!~c3 zEKC04U}155J1=e1?8{kX@^|pepN+dvUq+oE_oL>@5UvS$#X62yLjbp+)|mhB0|`o=GM**8if5B4q^WbY6KWEjPw}i zzn!d}EWZ;U<5|=(aI0fHi#n89kMR%OsJSq}#C_a+GLy8SBz+&RJef&a(je*kc@}jf z2|GW)tqnTrND@i(qcjOh{(_Y*Wt1z%j${318^WE)At2B~trmZ~Ae53at*#cum6rj#ng zbCUDtxKVrDJiDt9RM@$w5YY(a`-zx}lXY1;A=6B|Ht<4OXxKs(AcJDGNfnIeH{H;8 z8kaOZ zm94bCj`9ZwU6LLr>YqsZ zyRo-U&CX;P%zy`!njSdAhEx(ascoT-M%JKGYK|C3wS=}xM6FrW-PCko{Lrs zI_Z#KyZpk9RbMDiP_u0@2LVrokN|j!W$g}6GvNv+d6rO2n9NC@B@`15c9LItlXfr@ z7Icc6r!xmLm4lt)mD8DnNg8&2hFg8wKqUz~e}!8YDpJfP4X*V2ZC8>ca;2}fT}jg5 zO3$`ELXzN0e?ac4{8EzON?&Wck|e>E7S31*Px?By*rj*wo-X6Z#!qEo_jLCfZhrqF zdt=hLwYyf4s%^M4_?rNjQOGP{>r z|Mb-4nfEG}B|qI}UHrdALKZ*3Z0YCO7Vo@QS$@u_-JQSRp5aBSlD!XhmzL-5wJ)2J zyl_upqrLk(eKwTU)%Bs|j#{}de!YS^7?oCU*EUwWpNsT_+!kpg(>BXgQ1GLuHBODZ zbW(H`I&9czLmS?>3_{Z7bN#;Vh170wLx!P^gh5ER)rUyxc0tW@+N8*Jh)BRUB5F3X zegzc2C8LAfA(-|{NHQCGe%+fzupzJY z16150014{aoaUgtb)lSme4wy59z%#yUfAx8aw_P+FKk;3X$Ke4wCcmr=hLaF{ABDz z;Qb%GAJZ|R={hrLz+waoH+6k=V~yPiw0RKzkc=)Wr~+J|o(yMDFv`H0Npz3V8G&hg z8f4B0F1J}DYLrN#^-_om0tzN6Fe5E=bb8Uc@uKu#q8mQQNZ$O*X`tf}rv-G!u0zD8zCI1`x|bWh>~R)Dy}-HGI#x$gDJ>?XT^nM`w1 z=nXb;v&Xw@rHKIBA_*pMn8oGE?{2cYnVsBnq0nPGOAQLM8?t%E2MR> z>`Xbd3u+1^<2fI4new6-B?*Bb2pCixs8sI?sIG|v(p##zP^u0!a;aJgs0%P5wms-g z9E7RryD)HHUY(8%jCzG00V*)o;+_?{W(=qRqwCUdG(zLAc0rG5tVQ3iM5Lf%l@efh|F!N=guRPWkjz+Tx(t>4YJQDM&A`1;YO#2e6>B3JD|v>KD$$OzbtO zZbPa`!DDTXL_&y1ByR-4q1J3BUy|BDIkg=0AjW_Me8`S~92x^+RLhAVLO{UAK;JGc zC%}QmR+L4r7NwnG?f6!{wDtv4{K~X0;vj&0mLj{-nO}t7)HK3wFo)pM0pH20lSD^O z2W#sL#|p@s4Z>zb@oWe{YS8EiPh_yO0RAUCkVb{`Q5e-#IWj=ovQ0 zeFWH);Y_AZ6nEfzlps|xtn;j@yk)rXrA1&Lgk}Uo6u=a`$h#1CWU;5L$*=<=nta-H8@5 zJT(QR9Wb zA0!}&VL4<2}|&=Ze45vIz%M+Zxp#8g^?MN^ES*O7+PgtN?0gW-k; z=!2id7?q>ML6Yo26&zJwXhkHyoHr=0U{g{HXtd%$U346|4*r-y>tq>t!WeHMML>CZ z_mnyyB{_<15+|7>9R-0Zuoub!p9pDKiK1@OQ6)zQ`*_KKA(3h19U z#hcFVW>skjO**r)u7f%aNj$L0QuGC6=z*GNQvZ($w>P#H2}>rI2e@EtT>{_&l`h(u zE4y|x{B4*3QjIH1>*OpZ6U+`BcrmIPJNVViv?DN)?T zH;N}Q0?H0NDX9q*(1)<^LqMM}16vL857zGHDvRpsSycl$jX*?*m}wtRZk?5=uu@qM zl2kiZsPOBEc*dPU=?x>0C6aUjDj-$im?mCw47}F3VPhULC2*gLuCxwG>n!zT6&;c` zC);eN%?b2OFNKllm$jPo9Gn(Y2>?q$H=MQw{m?&Jd|4&w)>m=_i=4zntvN`C#Dn)1 zEjmbg2+u8g1W6C|3~5QH8E)vjU)J9#!wm!Ad9aUC93w1mm|yq4k5kFpk6Ww%Z*Nl< z$BG+LLr=uA8+tdSBW!-dAZkLn$!4p62cfrgtyk~s$ zjQ5P3b;{?A%R8Sl)K;YOF9$s+Mm`_qf;%VZ~CN;gXmv*LwJwfAk} zGpxA&D%6Ix{b#rK`_?1#|J4 z3loPI*OgD=d4d}1waLA_*c)GNmu(q<3SAf3_gnH!#$4L9mepP`-mkmX+-1I${To28 zSEA=S1W~0P4Kdyc$2~MuDATgwglB&O>Hc3ppURoJM{=ME)S7@D3R=WZ3hW^{ILMj` zLzJjs-0tnQ0C5qGKA?(J=%1azfA5F>$#Mf%KN|qFxBjV$+lX%e4GvIHSkso{-RBwj zFNWllS-fyVT4i}26cx7ba0jQ~38a!NNP3R}$IU&}-bybhESU(V<9+}gcx;MPU%iT?tSl@S&I delta 1611 zcmZA1TWm~07zgnE&zYUwGiMRgx+D?=71Tq8q(vm85AGrno+SE2B9T7zdE4Y9Jc#H+ zEfGT8S|yRR90?(4m3ms22qCVeUV8E1UVJm>?98SQ`!N5x?0mm(cUped^>o%XhX`5d zg*;4$BFu!@Zhv+iyV6joY4WE;wRQgSOmuIJAA${wXnnok=mi^^*UF4ar&2Af(0U-) z935NhFPxrx0Les3iNi>>6vlS@eHDk1ogr^A$wi9ML>t)U==mOh74mHmQ{)7h))P$e zHcVYy8hUzITQ4WYvs{TI;O!7n$*f+^uOISs7PKQRCriJSLGc2_{PKuSJ_@l&R!Uh~ zm8N?fLtdOfrhKJP{3OIISs{bc$_!G9pF*~pzSwTh(2=K+T~NL^tN0m+W#y5t_*sZ0 z<&jkU9K_N%%Ex5{ik~01GpG0kz06hL0e*?9uShDr(tmS~5*-LGQTe5t>5waaQI}Aj zWfbp(m?W>oJ%AEjV2|KMt$Y2Y$ag_ZOV~k$bzyoi-Z^VXGy#9qX?YFe&xrknWj8Ugzbq6R(v|sF$SzL`F*BO|wPQCKvA=P8CL;E?j@?AW{?4(R zh}hqc+eqvm96Q@i^$vg!T6UJO4~To!pCIb(ZFPT${j+8tfq8Q1nHU0VF7!6~ z!}%fi;&@cmkb7}FLLWlymIU3SOALemwdh`qK8)b6MK>9tk2rJ_(aEC_)ya!vB8~o) zUYu_h-Nc0c-Thrnq_uxwVs-Ltn2fZ4Iy4iJ_AiK<@>Qh$+i9Csr2VIC;~HHAagXU` z@xx1n@_*o)>_O%yPEoKiQS}Tz-Rd7^Z`fbeZfs@7ao)jDBk=YJ7M B2+sfj diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index ebcddcc26..17fad7155 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:dcad407d5de12c4b8fef310a382a485f4486eb71d26237ee70610372989dd439", + "digest": "sha256:30fb97eb0f2d5f094238aa32f0046ccd1cd433997a440f1116db49e78d378566", "services": [ { "name": "APIKeyService", @@ -173,6 +173,7 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -364,6 +365,13 @@ "/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", @@ -470,7 +478,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:dcad407d5de12c4b8fef310a382a485f4486eb71d26237ee70610372989dd439", + "digest": "sha256:30fb97eb0f2d5f094238aa32f0046ccd1cd433997a440f1116db49e78d378566", "services": [ { "name": "APIKeyService", @@ -633,6 +641,7 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -824,6 +833,13 @@ "/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", @@ -930,7 +946,7 @@ "kind": "protobuf", "package": "saas.gateway.auth.v1", "path": "contracts/api/auth-gateway/grpc/contract.binpb", - "digest": "sha256:5a866c4cf8868c46022cd46a68d2578a73618a01294e0b3f99651176b9f792fe", + "digest": "sha256:fbcdf2a9db8cd2e72f0765fda1e6d82a00b39979500b8fdd8b174bc1f2ecde8d", "services": [ { "name": "AuthGatewayService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 896678f23..1de7bd664 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:dcad407d5de12c4b8fef310a382a485f4486eb71d26237ee70610372989dd439 + digest: sha256:30fb97eb0f2d5f094238aa32f0046ccd1cd433997a440f1116db49e78d378566 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:dcad407d5de12c4b8fef310a382a485f4486eb71d26237ee70610372989dd439 + digest: sha256:30fb97eb0f2d5f094238aa32f0046ccd1cd433997a440f1116db49e78d378566 - name: auth-gateway endpoints: [grpc, rest] api-contracts: @@ -31,7 +31,7 @@ services: kind: protobuf package: saas.gateway.auth.v1 path: contracts/api/auth-gateway/grpc/contract.binpb - digest: sha256:5a866c4cf8868c46022cd46a68d2578a73618a01294e0b3f99651176b9f792fe + digest: sha256:fbcdf2a9db8cd2e72f0765fda1e6d82a00b39979500b8fdd8b174bc1f2ecde8d - name: cache endpoints: [read, write] - name: frontend 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 8bc3e8593..6824b57e4 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:dcad407d5de12c4b8fef310a382a485f4486eb71d26237ee70610372989dd439", + "digest": "sha256:30fb97eb0f2d5f094238aa32f0046ccd1cd433997a440f1116db49e78d378566", "services": [ { "name": "APIKeyService", @@ -173,6 +173,7 @@ "procedures": [ "/saas.accounts.v1.InstallationService/GetInstallation", "/saas.accounts.v1.InstallationService/InstallSolution", + "/saas.accounts.v1.InstallationService/ListInstallations", "/saas.accounts.v1.InstallationService/TransferInstallationOwnership", "/saas.accounts.v1.InstallationService/UninstallSolution" ] @@ -364,6 +365,13 @@ "/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", 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 2560eb356bc3537aa4adfe8c85649613522cf502..af91abb8cf85ef9acf49064ae46f572d72437ed4 100644 GIT binary patch delta 9660 zcmbtadvG1qdDl6+ckjJ>Ww5Vb;@Vh8!eAL2`2i)^7^q?iEMu@@Lz=V=_UdXSExy`S zcJH+;{#j_;5E2@Kd29@XR~l&Jj!haG#gNjWlNL%tUNbFC=}b!cA|Zu#8q(02&h+=4 zb9V1un|3zZYuAvEsUD2C^@yNSWEtBsN9|0w!M7r`RnZR ztTW{vh`ig}9z(E!!Uyf=b`@)do}U>5$sPYzsU~~qo#18Ly?%9cA6saT@i#+j_=(jr-*4M z68a+U#Gn{C?1u9qXimGKE%uG=!~)Tr_979v;bE`t3g4M=BjJcB_WY@ao353cZ4^-; zT7J`Os5R~psLY)ZKK8?GThxPAV?sE7H0Oq*Uou4J%LBkwx|ut1O~3fj?e~LauRbks zI`|Pzt2rHnUUOcAt}`KG#}>10cuB`?qM*?tk&D?dxYe!8Ke2qg&4>=lnN)2`Yj=#oR12GLeN!VIlnNdwOh zVOE}mtsZ7(x8B*nj7Q0!J|;DekB_qC!Y8edfC&RkJj%+c{TTA&ik0@u zig7oB?u6Hb6*zU$r4uE6ljWtcoP{9Kl%|3c2gNSEM>BK|yA6aE5Y zg}eJvYbLeC(Df(W5LO2RQIQj8XAbK`HbRGU*zp?9IE-Ey76M1?hI1aa^Ic4*`(Ed! zX+T9V1c7uzNzi^>glxaWO3K!XybGT96yxVezs69Gl+$B`xlgfkv$@d2Q>k6XMNVG( zG1PtXUT}w9oG}AVXk68OOt?h;iVMSkNU8fk6k*0*1UuLB~X&5EmWme4-jN5Wo z4CQD``VmK-V3jrYq7TC$@fB-Nu-=X7XPk0^ZQ87d3^sO>Y-}0~mWV-&&aeHd*zYzG zMB$P%L6|wdlv47q|6y*u!WIn&@qx0Xc2caC%ga6K-Z<(cTeaGLnc3Xxs#?#ouIH1R zK30Bd>9ZtWq#@Jl0{hl!#=o~PcuV;z7N2I;a#~Lxc-1`3R!Giii0w|Z59}mIqoG-~ zo@3^>m_?2z319y#9PK$)`4%gXSEV#?^*pmK%9F73H<)#?z09`LSq1R%hb*>UWX1eU zNb`@G)oU-cE9uN4gT%kdN*i!+w!DZf|AdtW@@o;)Ut-o}c~ZG*zRavGd6Hc<|BP9e zDpHJl;Tn~y`N|n9yQ}7()3o;OUuEWZar>qErq8_cjsB|jDyw`K*H1YkBCO@xbcH;v zST(=HtaI(JDIKt?^%~>vFk|7(gXK$E{2H@XYArBa>^0US4F(b*D}9Y!Ii~qw(86Ca z^Cy`W7_m>Z%1<&aNE)>8gSHkV2|NFFTMLpzT6i5=_+h36Nro2wwygz8l3MstTMLSa zE&r~q1xbSz{-G^PNrD#sv8@G3f)@TMrv*ua7T!E#CA9G4wif92Z!z=jObg6ZT6l|9 z-p;h3h|t1MnRTVsf}%kS|4drATWJAl>s`iwiyI4{ub0a#ewSGnXf1GD>|M5YS2~0i z-evm_Xf1H?@pEQ=il@H7l@@-^Dxc!1FGw1+@b7IcND_AbMOzD!L|S-{nbv=@V!~c3 zEKC04U}155J1=e1?8{kX@^|pepN+dvUq+oE_oL>@5UvS$#X62yLjbp+)|mhB0|`o=GM**8if5B4q^WbY6KWEjPw}i zzn!d}EWZ;U<5|=(aI0fHi#n89kMR%OsJSq}#C_a+GLy8SBz+&RJef&a(je*kc@}jf z2|GW)tqnTrND@i(qcjOh{(_Y*Wt1z%j${318^WE)At2B~trmZ~Ae53at*#cum6rj#ng zbCUDtxKVrDJiDt9RM@$w5YY(a`-zx}lXY1;A=6B|Ht<4OXxKs(AcJDGNfnIeH{H;8 z8kaOZ zm94bCj`9ZwU6LLr>YqsZ zyRo-U&CX;P%zy`!njSdAhEx(ascoT-M%JKGYK|C3wS=}xM6FrW-PCko{Lrs zI_Z#KyZpk9RbMDiP_u0@2LVrokN|j!W$g}6GvNv+d6rO2n9NC@B@`15c9LItlXfr@ z7Icc6r!xmLm4lt)mD8DnNg8&2hFg8wKqUz~e}!8YDpJfP4X*V2ZC8>ca;2}fT}jg5 zO3$`ELXzN0e?ac4{8EzON?&Wck|e>E7S31*Px?By*rj*wo-X6Z#!qEo_jLCfZhrqF zdt=hLwYyf4s%^M4_?rNjQOGP{>r z|Mb-4nfEG}B|qI}UHrdALKZ*3Z0YCO7Vo@QS$@u_-JQSRp5aBSlD!XhmzL-5wJ)2J zyl_upqrLk(eKwTU)%Bs|j#{}de!YS^7?oCU*EUwWpNsT_+!kpg(>BXgQ1GLuHBODZ zbW(H`I&9czLmS?>3_{Z7bN#;Vh170wLx!P^gh5ER)rUyxc0tW@+N8*Jh)BRUB5F3X zegzc2C8LAfA(-|{NHQCGe%+fzupzJY z16150014{aoaUgtb)lSme4wy59z%#yUfAx8aw_P+FKk;3X$Ke4wCcmr=hLaF{ABDz z;Qb%GAJZ|R={hrLz+waoH+6k=V~yPiw0RKzkc=)Wr~+J|o(yMDFv`H0Npz3V8G&hg z8f4B0F1J}DYLrN#^-_om0tzN6Fe5E=bb8Uc@uKu#q8mQQNZ$O*X`tf}rv-G!u0zD8zCI1`x|bWh>~R)Dy}-HGI#x$gDJ>?XT^nM`w1 z=nXb;v&Xw@rHKIBA_*pMn8oGE?{2cYnVsBnq0nPGOAQLM8?t%E2MR> z>`Xbd3u+1^<2fI4new6-B?*Bb2pCixs8sI?sIG|v(p##zP^u0!a;aJgs0%P5wms-g z9E7RryD)HHUY(8%jCzG00V*)o;+_?{W(=qRqwCUdG(zLAc0rG5tVQ3iM5Lf%l@efh|F!N=guRPWkjz+Tx(t>4YJQDM&A`1;YO#2e6>B3JD|v>KD$$OzbtO zZbPa`!DDTXL_&y1ByR-4q1J3BUy|BDIkg=0AjW_Me8`S~92x^+RLhAVLO{UAK;JGc zC%}QmR+L4r7NwnG?f6!{wDtv4{K~X0;vj&0mLj{-nO}t7)HK3wFo)pM0pH20lSD^O z2W#sL#|p@s4Z>zb@oWe{YS8EiPh_yO0RAUCkVb{`Q5e-#IWj=ovQ0 zeFWH);Y_AZ6nEfzlps|xtn;j@yk)rXrA1&Lgk}Uo6u=a`$h#1CWU;5L$*=<=nta-H8@5 zJT(QR9Wb zA0!}&VL4<2}|&=Ze45vIz%M+Zxp#8g^?MN^ES*O7+PgtN?0gW-k; z=!2id7?q>ML6Yo26&zJwXhkHyoHr=0U{g{HXtd%$U346|4*r-y>tq>t!WeHMML>CZ z_mnyyB{_<15+|7>9R-0Zuoub!p9pDKiK1@OQ6)zQ`*_KKA(3h19U z#hcFVW>skjO**r)u7f%aNj$L0QuGC6=z*GNQvZ($w>P#H2}>rI2e@EtT>{_&l`h(u zE4y|x{B4*3QjIH1>*OpZ6U+`BcrmIPJNVViv?DN)?T zH;N}Q0?H0NDX9q*(1)<^LqMM}16vL857zGHDvRpsSycl$jX*?*m}wtRZk?5=uu@qM zl2kiZsPOBEc*dPU=?x>0C6aUjDj-$im?mCw47}F3VPhULC2*gLuCxwG>n!zT6&;c` zC);eN%?b2OFNKllm$jPo9Gn(Y2>?q$H=MQw{m?&Jd|4&w)>m=_i=4zntvN`C#Dn)1 zEjmbg2+u8g1W6C|3~5QH8E)vjU)J9#!wm!Ad9aUC93w1mm|yq4k5kFpk6Ww%Z*Nl< z$BG+LLr=uA8+tdSBW!-dAZkLn$!4p62cfrgtyk~s$ zjQ5P3b;{?A%R8Sl)K;YOF9$s+Mm`_qf;%VZ~CN;gXmv*LwJwfAk} zGpxA&D%6Ix{b#rK`_?1#|J4 z3loPI*OgD=d4d}1waLA_*c)GNmu(q<3SAf3_gnH!#$4L9mepP`-mkmX+-1I${To28 zSEA=S1W~0P4Kdyc$2~MuDATgwglB&O>Hc3ppURoJM{=ME)S7@D3R=WZ3hW^{ILMj` zLzJjs-0tnQ0C5qGKA?(J=%1azfA5F>$#Mf%KN|qFxBjV$+lX%e4GvIHSkso{-RBwj zFNWllS-fyVT4i}26cx7ba0jQ~38a!NNP3R}$IU&}-bybhESU(V<9+}gcx;MPU%iT?tSl@S&I delta 1611 zcmZA1TWm~07zgnE&zYUwGiMRgx+D?=71Tq8q(vm85AGrno+SE2B9T7zdE4Y9Jc#H+ zEfGT8S|yRR90?(4m3ms22qCVeUV8E1UVJm>?98SQ`!N5x?0mm(cUped^>o%XhX`5d zg*;4$BFu!@Zhv+iyV6joY4WE;wRQgSOmuIJAA${wXnnok=mi^^*UF4ar&2Af(0U-) z935NhFPxrx0Les3iNi>>6vlS@eHDk1ogr^A$wi9ML>t)U==mOh74mHmQ{)7h))P$e zHcVYy8hUzITQ4WYvs{TI;O!7n$*f+^uOISs7PKQRCriJSLGc2_{PKuSJ_@l&R!Uh~ zm8N?fLtdOfrhKJP{3OIISs{bc$_!G9pF*~pzSwTh(2=K+T~NL^tN0m+W#y5t_*sZ0 z<&jkU9K_N%%Ex5{ik~01GpG0kz06hL0e*?9uShDr(tmS~5*-LGQTe5t>5waaQI}Aj zWfbp(m?W>oJ%AEjV2|KMt$Y2Y$ag_ZOV~k$bzyoi-Z^VXGy#9qX?YFe&xrknWj8Ugzbq6R(v|sF$SzL`F*BO|wPQCKvA=P8CL;E?j@?AW{?4(R zh}hqc+eqvm96Q@i^$vg!T6UJO4~To!pCIb(ZFPT${j+8tfq8Q1nHU0VF7!6~ z!}%fi;&@cmkb7}FLLWlymIU3SOALemwdh`qK8)b6MK>9tk2rJ_(aEC_)ya!vB8~o) zUYu_h-Nc0c-Thrnq_uxwVs-Ltn2fZ4Iy4iJ_AiK<@>Qh$+i9Csr2VIC;~HHAagXU` z@xx1n@_*o)>_O%yPEoKiQS}Tz-Rd7^Z`fbeZfs@7ao)jDBk=YJ7M B2+sfj 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 65be4c738..f912632ec 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:dcad407d5de12c4b8fef310a382a485f4486eb71d26237ee70610372989dd439 + contract-digest: sha256:30fb97eb0f2d5f094238aa32f0046ccd1cd433997a440f1116db49e78d378566 services: - APIKeyService - AccessibleScopeService @@ -41,6 +41,7 @@ sources: - PrincipalService - ResourceFollowService - SSOAdminService + - SolutionEntitlementService - SolutionRegistryService - TeamService - UsageService From 28c1e3c1ff4a1d42921ef301db8187952d59c109 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:28:47 -0400 Subject: [PATCH 4/4] fix(ci): gofmt the gateway struct and regenerate the companion-owned swagger (#949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI phases were red on this branch, both from steps skipped here. lint: adding the solutionEntitlements field to Gateway left gateway.go's struct unaligned; gofmt was run on every other touched file but not this one. sync-drift: generated/openapi-raw/api.swagger.json is owned by the proto companion image, and the --local Go template used for the proto change deliberately does not emit it, so the new SolutionEntitlementService's tag was missing. Regenerated through the companion (CLI 0.1.168, the CI pin), which changed only that file — three lines, the new service's tag — and a second run is a no-op. go generate ./pkg/cataloggen then carries the same tag into openapi/api.swagger.json. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../services/accounts/generated/openapi-raw/api.swagger.json | 3 +++ module/services/accounts/openapi/api.swagger.json | 3 +++ module/services/auth-gateway/code/gateway.go | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 3e4bad32b..5b172552e 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" }, diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index a9f5793d2..f7c0873a1 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -10986,6 +10986,9 @@ { "name": "ResourceFollowService" }, + { + "name": "SolutionEntitlementService" + }, { "name": "SolutionRegistryService" }, diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index 3b08679f2..e724611bb 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -63,7 +63,7 @@ type Gateway struct { // closed rather than answering an empty projection, which would retract every // solution a viewer is currently using. solutionEntitlements solutionEntitlementClient - workContext *workContextVerifier + workContext *workContextVerifier } // NewGateway constructs a gateway with explicit route matching.