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..50ab84c9c 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 @@ -222,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/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 2560eb356..af91abb8c 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/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/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 2560eb356..af91abb8c 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/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 19bc0b85b..32382d232 100644 Binary files a/module/contracts/api/auth-gateway/grpc/contract.binpb and b/module/contracts/api/auth-gateway/grpc/contract.binpb differ diff --git a/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/solution_entitlements.proto new file mode 100644 index 000000000..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/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/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..8c0818183 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,174 @@ 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() + } + + summaries, err := s.listInstallationSummaries(ctx, executor, orgID, + installationStatusColumn(status), cursor, limit, "TRUE") + if err != nil { + return nil, w.Wrapf(err, "failed to list installations") + } + return summaries, nil +} + +// installationHealthLabel is resolveInstallationHealth as ONE SQL expression over +// an `installations i` row joined to its agent principal as `hp`, so a listing +// resolves health for every row in the same statement instead of three or four +// round trips per row. The per-row form multiplied every menu read by the number +// of entitled installations (#949 review f4). +// +// It must answer exactly what resolveInstallationHealth answers, in the same +// precedence: a missing or revoked agent, then a disabled one, then no current +// admin among the owner and co-owners, then a missing or expired standing grant. +// TestInstallationListingHealthMatchesGetInstallation holds the two together for +// every state, the way the scope point check and listing are held together. +const installationHealthLabel = `CASE + WHEN hp.id IS NULL OR hp.revoked_at IS NOT NULL THEN 'agent_revoked' + WHEN hp.disabled_at IS NOT NULL THEN 'agent_disabled' + WHEN NOT EXISTS ( + SELECT 1 FROM organization_members hm + WHERE hm.org_id = i.org_id + AND hm.user_id = ANY (ARRAY[i.owner_principal_id] || i.co_owner_principal_ids) + AND hm.role IN ('owner', 'admin') + ) THEN 'no_eligible_owner' + WHEN NOT EXISTS ( + SELECT 1 FROM scope_grants hg + JOIN scope_nodes hn ON hn.id = i.root_scope_node_id + WHERE hg.org_id = i.org_id + AND hg.subject_kind = 'principal' + AND hg.subject_id = i.agent_principal_id + AND hg.scope_path @> hn.scope_path + AND (hg.expires_at IS NULL OR hg.expires_at > now()) + ) THEN 'standing_grant_missing' + ELSE 'healthy' + END` + +func installationHealthFromLabel(label string) gen.InstallationHealth { + switch label { + case "agent_revoked": + return gen.InstallationHealth_INSTALLATION_HEALTH_AGENT_REVOKED + case "agent_disabled": + return gen.InstallationHealth_INSTALLATION_HEALTH_AGENT_DISABLED + case "no_eligible_owner": + return gen.InstallationHealth_INSTALLATION_HEALTH_NO_ELIGIBLE_OWNER + case "standing_grant_missing": + return gen.InstallationHealth_INSTALLATION_HEALTH_STANDING_GRANT_MISSING + case "healthy": + return gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY + default: + return gen.InstallationHealth_INSTALLATION_HEALTH_UNSPECIFIED + } +} + +// installationListingColumns is installationColumns qualified by the listing's +// `i` alias, followed by the health label — scanned by scanInstallation plus one. +const installationListingColumns = `i.id::text, i.org_id::text, i.agent_principal_id::text, + i.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 + } + defer rows.Close() + + var summaries []*gen.InstallationSummary + for rows.Next() { + var label string + installation, e := scanInstallation(withHealthLabel{row: rows, label: &label}) + if e != nil { + return nil, e + } + summaries = append(summaries, &gen.InstallationSummary{ + Installation: installation, + Health: installationHealthFromLabel(label), + }) + } + return summaries, rows.Err() +} + // GetInstallation loads one installation and resolves its health live from the // current agent lifecycle, owner admin status, and standing grant. func (s *PostgresStore) GetInstallation(ctx context.Context, orgID, installationID string) (*gen.Installation, gen.InstallationHealth, error) { 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..ee9b1a0bc --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements.go @@ -0,0 +1,98 @@ +package infra + +import ( + "context" + "fmt" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/wool" + "github.com/google/uuid" +) + +// ListSolutionEntitlements answers what one subject may use inside one org +// (issue #949): the org's active installations, narrowed to those whose +// authority-root scope node the subject may act on. +// +// It is ONE statement on purpose. The installation set and the grant + share +// union are two authority facts, and reading them in two round trips lets a +// revoke land between them — producing a menu entry whose grant is already gone, +// and a fingerprint describing a state that never existed. Joined here, both are +// read at one snapshot inside the caller's org transaction. +// +// The admitted-scope subquery is `accessibleScopesQuery`, the same union +// CheckAccess and ListAccessibleScopes resolve through — not a second predicate +// that means to agree with it. That is what makes it impossible for this listing +// to offer a solution an authority check would then deny. +// +// The join is on `n.id::text = root_scope_node_id::text`, the projection the scope +// listing itself returns as node_id, for the reason CanReadScopeNode compares +// there: it keeps membership and listing reading one set by construction. +func (s *PostgresStore) ListSolutionEntitlements( + ctx context.Context, orgID, subjectID string, pageToken string, limit int, +) ([]*gen.SolutionEntitlement, error) { + w := wool.Get(ctx).In("ListSolutionEntitlements", wool.Field("org_id", orgID)) + executor := s.getQueryExecutor(ctx) + + cursor := "" + if pageToken != "" { + parsed, err := uuid.Parse(pageToken) + if err != nil { + return nil, business.NewStoreError( + fmt.Errorf("page_token %q is not a cursor this listing issued", pageToken), + business.ErrTypeValidation, + ) + } + cursor = parsed.String() + } + + // $1 subject, $2 resource_type, $3 action, $4 org, $5 the node kind this + // narrows to, $6 platform admissibility — the parameter contract + // accessibleScopesQuery documents. The shared listing numbers its own + // parameters after these. + // + // The node predicate is the node KIND rather than a candidate id set: a + // solution's authority root is the one node per install of kind `solution`, so + // asking the union for accessible solution nodes is the question, and it needs + // no array of ids assembled by a prior query. + admitted, err := accessibleScopesQuery( + gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, "node_id", "n.kind = $5", "$6") + if err != nil { + return nil, err + } + + // Only ACTIVE installations. A revoked installation's standing grant is gone, + // so the union would usually exclude it anyway — but "usually" is not a + // boundary: a team grant written directly at the solution node outlives the + // uninstall (the node is retained for a reinstall to reuse), and without this + // predicate an uninstalled solution would stay in a granted team's menu. + // + // Through the SAME listing ListInstallations reads, narrowed by the admitted + // set, so the two reads share one query, one keyset and one health definition, + // and health for every row is resolved inside this one statement. + summaries, err := s.listInstallationSummaries(ctx, executor, orgID, "active", cursor, limit, + "i.root_scope_node_id::text IN ("+admitted+")", + subjectID, business.ResourceTypeSolution, business.ActionUseSolution, orgID, + business.ScopeNodeKindSolution, + platformReadAdmissible(ctx, gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, business.ActionUseSolution)) + if err != nil { + return nil, w.Wrapf(err, "failed to list solution entitlements") + } + + out := make([]*gen.SolutionEntitlement, 0, len(summaries)) + for _, summary := range summaries { + installation := summary.GetInstallation() + out = append(out, &gen.SolutionEntitlement{ + 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: summary.GetHealth() == gen.InstallationHealth_INSTALLATION_HEALTH_HEALTHY, + }) + } + return out, nil +} + +var _ business.SolutionEntitlementStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go new file mode 100644 index 000000000..48552c3a3 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_solution_entitlements_test.go @@ -0,0 +1,541 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "strings" + "testing" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/jackc/pgx/v5" + "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") +} + +// ============================================================================ +// 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/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/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/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/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/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..e724611bb 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -58,7 +58,12 @@ type Gateway struct { registeredTransport http.RoundTripper // registrationReplay makes each solution-registration credential single-use. registrationReplay *registrationReplayGuard - workContext *workContextVerifier + // solutionEntitlements answers what one viewer may use (#949). Set after + // construction, like workContext: a nil client fails the entitlement surface + // closed rather than answering an empty projection, which would retract every + // solution a viewer is currently using. + solutionEntitlements solutionEntitlementClient + workContext *workContextVerifier } // NewGateway constructs a gateway with explicit route matching. 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..5245dea64 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_entitlements.go @@ -0,0 +1,293 @@ +package main + +// The per-viewer solution entitlement surface (issue #949). +// +// The host's solution projections — the navigation menu and the per-client +// surface listing — used to answer the same deployment-wide set to every caller, +// because what a viewer may USE was nowhere in the request. This endpoint is what +// puts it there. +// +// Why it lives in the gateway, and not in the route handler that needs it: the +// frontend's own /api/* routes are served by Next in-process and are never +// proxied through here, so nothing stamps identity on them and a browser can put +// any x-org-id it likes on one. Verified identity exists at exactly one place in +// this system — behind ext_authz, whose OkResponse headers this file reads. A +// projection that derived the organization itself would be trusting a claim it +// had not verified, and a caller could read another tenant's menu by editing it. +// +// So the division is the same one gateway_solutions.go states for proxied +// solution traffic: the gateway performs authentication and identity projection, +// and accounts remains the authorization authority. This handler authenticates, +// projects the tenant and the viewer, and asks accounts — which answers from the +// installation set and the scope grant union in one transaction. + +import ( + "context" + "errors" + "net/http" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// solutionEntitlementsSegment answers what the CALLING viewer may use, from the +// identity ext_authz verifies on the request. It is the one `/solutions/*` segment +// whose answer differs per caller. +const solutionEntitlementsSegment = "_entitlements" + +// handleSolutionViewerSegment dispatches the `/solutions/*` segments whose answer +// is per viewer. It lives here rather than in the switch in gateway_solutions.go +// so that file keeps naming nothing about installations or entitlements: the +// boundary test in module/tools reads it to assert that the PROXY decision — which +// is still deployment-wide — consults no tenant state, and a dispatch line there +// would defeat a check that is otherwise exact. +func (g *Gateway) handleSolutionViewerSegment(w http.ResponseWriter, r *http.Request, segment string) bool { + if segment != solutionEntitlementsSegment { + return false + } + g.handleSolutionEntitlements(w, r) + return true +} + +const listSolutionEntitlementsMethod = "/saas.accounts.v1.SolutionEntitlementService/ListSolutionEntitlements" + +// solutionEntitlementRefusalHeader names which of 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 +// 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")) { + w.Header().Set(solutionEntitlementRefusalHeader, refusalInternalCredential) + httpError(w, http.StatusUnauthorized, "unauthorized") + return + } + if g.solutionEntitlements == nil { + // No authority client wired. Fail closed and distinguishably: an empty + // projection here would retract every solution a viewer is currently + // using, which is exactly the outage-as-deletion this surface must not do. + httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") + return + } + + // Same identity discipline as every protected route: drop caller-supplied + // identity FIRST, so nothing below can read a header the caller wrote, then + // run ext_authz and stamp what it returns. + stripAllIdentityHeaders(r) + checkResp, err := g.authz.Check(r.Context(), buildCheckRequest(r)) + if err != nil { + httpError(w, http.StatusInternalServerError, "auth check failed") + return + } + if denied := checkResp.GetDeniedResponse(); denied != nil { + code := int(denied.GetStatus().GetCode()) + if code == 0 { + code = http.StatusUnauthorized + } + httpError(w, code, denied.GetBody()) + return + } + if int(checkResp.GetStatus().GetCode()) != 0 { + httpError(w, http.StatusForbidden, "forbidden") + return + } + injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) + + // The verified identity, read from what the check just stamped — never from + // the request as it arrived. + org := r.Header.Get("X-Org-Id") + viewer := effectiveViewer(r) + if org == "" || viewer == "" { + // Authenticated, but carrying no organization: a session that has not + // selected one cannot have an org-scoped entitlement set. That is a + // property of the credential, not an outage, and not an empty tenant's + // menu either — so it is refused rather than answered with []. + w.Header().Set(solutionEntitlementRefusalHeader, refusalNoOrganization) + httpError(w, http.StatusForbidden, "no organization in this session") + return + } + + // Every read here fans into the authority — the grant union plus health for + // each entitled installation — so it draws on the same per-org budget as the + // equivalent catalog read. Without it this would be the one unmetered path + // from a signed-in browser into accounts (the defect #513 closed for the + // solution proxy): the menu polls, and nothing in front of it limits a poll. + // The limiter keys on the X-Org-Id the check just stamped, and fails open on + // a limiter-backend outage exactly as a StandardRead catalog route does. + entry := &RouteEntry{ + Service: "solution:_entitlements", + UpstreamPath: r.URL.Path, + Protected: true, + RateLimitClass: edgeRateLimitClassStandardRead, + } + answer := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + entries, err := g.collectSolutionEntitlements(r.Context(), org, viewer) + if err != nil { + // The authority could not answer. 503, never an empty list: the + // frontend distinguishes the two and holds its last projection rather + // than emptying a working menu. + httpError(w, http.StatusServiceUnavailable, "solution entitlement authority unavailable") + return + } + writeSolutionJSON(w, http.StatusOK, solutionEntitlementProjection{ + Org: org, Viewer: viewer, Usable: entries, + }) + }) + if g.rateLimiter == nil { + answer.ServeHTTP(w, r) + return + } + g.rateLimiter.Middleware(limiterFailureModeFor(entry), rateLimitClassFor(entry), + entry.AuthenticationFactorAttempt, answer).ServeHTTP(w, r) +} + +// effectiveViewer is whose entitlements the menu is for: the impersonated user +// while an impersonation is active, otherwise the caller. +// +// ext_authz stamps X-User-Id with the token's `sub`, which during impersonation +// stays the REAL actor, and names the user being viewed in X-Acting-As-User-Id. +// accounts authorizes every request against that effective subject +// (auth.RequestIdentity.EffectiveSubject), so a projection that read X-User-Id +// alone would show an impersonating administrator their own grants inside the +// impersonated user's organization — never the menu that user actually sees, +// which is the one thing impersonation is used to check. +func effectiveViewer(r *http.Request) string { + if acting := r.Header.Get("X-Acting-As-User-Id"); acting != "" { + return acting + } + return r.Header.Get("X-User-Id") +} + +// collectSolutionEntitlements reads every page of the viewer's entitled set. A +// partial answer is not returned as a short list: the caller cannot tell it from +// a genuinely smaller set, and would narrow a menu on it. +func (g *Gateway) collectSolutionEntitlements(ctx context.Context, org, viewer string) ([]solutionEntitlementEntry, error) { + entries := make([]solutionEntitlementEntry, 0) + token := "" + for page := 0; page < solutionEntitlementPageLimit; page++ { + resp, err := g.solutionEntitlements.List(ctx, &accountsv1.ListSolutionEntitlementsRequest{ + OrgId: org, + SubjectId: viewer, + PageSize: solutionEntitlementPageSize, + PageToken: token, + }) + if err != nil { + return nil, err + } + for _, entitlement := range resp.GetEntitlements() { + entries = append(entries, solutionEntitlementEntry{ + 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..2c560b891 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_solution_entitlements_test.go @@ -0,0 +1,338 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// fakeSolutionEntitlements stands in for accounts. It records what it was asked, +// because the point of this endpoint is WHICH org and subject reach the authority: +// they must be the ones ext_authz verified, never values the caller supplied. +type fakeSolutionEntitlements struct { + calls []*accountsv1.ListSolutionEntitlementsRequest + // pages is served in order, so the paging loop can be exercised. + pages []*accountsv1.ListSolutionEntitlementsResponse + err error +} + +func (f *fakeSolutionEntitlements) List( + _ context.Context, req *accountsv1.ListSolutionEntitlementsRequest, +) (*accountsv1.ListSolutionEntitlementsResponse, error) { + f.calls = append(f.calls, req) + if f.err != nil { + return nil, f.err + } + if len(f.pages) == 0 { + return &accountsv1.ListSolutionEntitlementsResponse{}, nil + } + page := f.pages[0] + if len(f.pages) > 1 { + f.pages = f.pages[1:] + } + return page, nil +} + +func entitlementGateway(t *testing.T) (*Gateway, *fakeSolutionEntitlements, ed25519.PrivateKey) { + t.Helper() + gw, _, _, priv := newGatewayHarness(t) + authority := &fakeSolutionEntitlements{} + gw.solutionEntitlements = authority + return gw, authority, priv +} + +func entitlementRequest(internalToken, bearer string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/solutions/_entitlements", nil) + if internalToken != "" { + req.Header.Set("X-Codefly-Internal-Token", internalToken) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return req +} + +func decodeEntitlements(t *testing.T, body []byte) solutionEntitlementProjection { + t.Helper() + var out solutionEntitlementProjection + require.NoError(t, json.Unmarshal(body, &out)) + return out +} + +// The whole reason this endpoint exists: a projection cannot derive a verified +// organization, so it asks the component that authenticated. The org and subject +// the authority is asked about must therefore be ext_authz's, and an +// unauthenticated caller must be refused rather than answered. +func TestGatewaySolutionEntitlements_AsksAboutTheVerifiedIdentity(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + token := signValidToken(t, priv) + authority.pages = []*accountsv1.ListSolutionEntitlementsResponse{{ + Entitlements: []*accountsv1.SolutionEntitlement{ + {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) +} + +// signImpersonationToken mints the token an impersonation session carries: `sub` +// stays the real actor and `acting` names the user being viewed. +func signImpersonationToken(t *testing.T, priv ed25519.PrivateKey, actor, acting, org string) string { + t.Helper() + return signAccessToken(t, priv, accessKeyID(priv.Public().(ed25519.PublicKey)), accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "saas-starter", + Subject: actor, + Audience: jwt.ClaimStrings{"saas-starter"}, + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Second)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ID: "jti-impersonation", + }, + OrgID: org, + OrgRole: "member", + SessionID: uuid.Must(uuid.NewV7()).String(), + ActingAsUserID: acting, + }) +} + +// During impersonation the menu is the IMPERSONATED user's. accounts authorizes +// against the effective subject, so asking about the real actor would show an +// administrator their own grants inside someone else's organization — never the +// menu the user they are debugging actually sees. +func TestGatewaySolutionEntitlements_ImpersonationAsksAboutTheImpersonatedUser(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + actor := uuid.Must(uuid.NewV7()).String() + viewed := uuid.Must(uuid.NewV7()).String() + org := uuid.Must(uuid.NewV7()).String() + + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", signImpersonationToken(t, priv, actor, viewed, org))) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.Equal(t, viewed, authority.calls[0].GetSubjectId(), "the effective subject, not the real actor") + require.Equal(t, org, authority.calls[0].GetOrgId()) + require.Equal(t, viewed, decodeEntitlements(t, w.Body.Bytes()).Viewer) +} + +// A caller cannot put itself into someone else's menu by sending the +// impersonation header: it is stripped before ext_authz stamps its own. +func TestGatewaySolutionEntitlements_IgnoresCallerSuppliedActingHeader(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + req := entitlementRequest("test-internal-token", signValidToken(t, priv)) + req.Header.Set("X-Acting-As-User-Id", "33333333-3333-3333-3333-333333333333") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, authority.calls, 1) + require.NotEqual(t, "33333333-3333-3333-3333-333333333333", authority.calls[0].GetSubjectId()) +} + +// Every read fans into the authority, so it spends the per-org budget like the +// equivalent catalog read. Unmetered, a signed-in browser could drive unbounded +// accounts load through the menu poll. +func TestGatewaySolutionEntitlements_SpendsThePerOrgBudget(t *testing.T) { + gw, authority, priv := entitlementGateway(t) + // effective budget = limit(1) + burst(max(1/5,1)=1) = 2 requests / org / min. + gw.rateLimiter = NewRateLimiter(1) + // One token, so every request keys on the same stamped X-Org-Id. + token := signValidToken(t, priv) + + got429 := false + for i := 0; i < 5; i++ { + w := httptest.NewRecorder() + gw.ServeHTTP(w, entitlementRequest("test-internal-token", token)) + if w.Code == http.StatusTooManyRequests { + got429 = true + break + } + require.Equal(t, http.StatusOK, w.Code) + } + require.True(t, got429, "the entitlement read must draw on the per-org budget") + require.Less(t, len(authority.calls), 5, "a throttled request must not reach the authority") +} + +// The endpoint's OWN refusals are named, so a caller can tell a fault in its +// cluster credential from a verdict on the user's. Both are 401 on the wire. +func TestGatewaySolutionEntitlements_NamesItsOwnRefusals(t *testing.T) { + gw, _, priv := entitlementGateway(t) + + internal := httptest.NewRecorder() + gw.ServeHTTP(internal, entitlementRequest("wrong-internal-token", signValidToken(t, priv))) + require.Equal(t, http.StatusUnauthorized, internal.Code) + require.Equal(t, "internal-credential", internal.Header().Get("X-Codefly-Entitlement-Refusal")) + + user := httptest.NewRecorder() + gw.ServeHTTP(user, entitlementRequest("test-internal-token", "not-a-token")) + require.Equal(t, http.StatusUnauthorized, user.Code) + require.Empty(t, user.Header().Get("X-Codefly-Entitlement-Refusal"), + "a refused USER credential is ext_authz's verdict, not this endpoint's refusal") +} diff --git a/module/services/auth-gateway/code/gateway_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..46d825d8f 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,52 @@ activating a remote. origin, so withholding it would not keep the origin from anyone who can use a surface — it would only make the answer unusable. The manifest path and the backend service are still withheld. + +**Where the identity comes from, and why not from here.** Neither route may derive +the organization. `src/lib/auth-session.ts` can read an `org` claim, but +`decodeJWTPayload` only base64-decodes — it verifies nothing — so a route that +narrowed on it would let a caller read another tenant's menu by editing one field. +These `/api/*` paths are also not proxied through the gateway (`src/proxy.ts` +forwards `/v1/*` and `/saas.accounts.v1.*` only), so no `ext_authz` stamp reaches +them either. `src/solutions/entitlements.ts` therefore forwards the caller's +credential to `GET /solutions/_entitlements` on the gateway, which authenticates +it, projects the organization and viewer from its own check, and answers from +accounts. `module/tools/solution_registration_boundary_test.go` holds both halves: +the projections must consult that read, and must not name the local decoders. + +**`available` is a client contract.** An installed, granted solution whose +installation is unhealthy stays listed with `available: false` — the grant exists, +so hiding it would send someone looking for one that already does. It is a new +field on both projections: the host's own sidebar renders such a solution disabled, +and **a registered client of the surfaces projection must honour it the same way** +— a client that ignores the field will offer an unavailable solution's surfaces as +usable. 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/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 2560eb356..af91abb8c 100644 Binary files a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb and b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb differ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 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 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..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"); @@ -63,6 +60,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 +109,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 +539,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 +551,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 +588,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 +598,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 +619,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..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 @@ -13,8 +13,17 @@ import { type RegistrationRefusal, } from "@/solutions/registration-log"; import { - loadSolutions, + entitlementFailureResponse, + isEntitlementFailure, + viewerEntitlements, +} from "@/solutions/entitlements"; +import { + cachedProjection, + entitledSolutions, navProjection, +} from "@/solutions/projections"; +import { + loadSolutionsWithRevision, parseManifest, registerSolution, type SolutionWriteResult, @@ -288,20 +297,44 @@ 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 (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); + } + const registered = await loadSolutionsWithRevision(); // An empty registry and an unreadable one must not render the same: the // first correctly shows no solutions, the second would silently empty a // working navigation. if (registered === "unavailable") { return Response.json({ error: "registry_unavailable" }, { status: 503 }); } - 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..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 @@ -33,12 +33,65 @@ function manifest( }); } -/** A gateway serving one registry snapshot, the only call this route makes. */ +interface EntitlementAnswer { + /** Solution ids the viewer may use; defaults to every registered one. */ + usable?: string[]; + /** Ids whose installation is unhealthy — entitled, but not routable. */ + unhealthy?: string[]; + /** Answer the entitlement read with this status instead of 200. */ + status?: number; + /** The gateway's own refusal name (X-Codefly-Entitlement-Refusal), if any. */ + refusal?: string; + org?: string; + viewer?: string; +} + +/** + * A gateway serving the two reads this route makes: the registry snapshot, and + * the per-viewer entitlement projection that narrows it (#949). + */ function gatewayServing( solutions: Array<{ id: string; status: string; manifest: string }>, + entitlements: EntitlementAnswer = {}, ) { - return vi.fn(async (input: string | URL) => { - if (new URL(String(input)).pathname !== "/solutions/_registry") { + return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const target = + input instanceof Request ? input.url : String(input as string | URL); + const path = new URL(target).pathname; + if (path === "/solutions/_entitlements") { + if (entitlements.status !== undefined && entitlements.status !== 200) { + const headers = new Headers(); + if (entitlements.refusal) { + headers.set("X-Codefly-Entitlement-Refusal", entitlements.refusal); + } + return new Response(JSON.stringify({ error: "refused" }), { + status: entitlements.status, + headers, + }); + } + // The gateway answers 401 itself when no credential was forwarded; this + // stub stands in for ext_authz making that call. + const headers = new Headers(init?.headers); + if (!headers.get("authorization")) { + return new Response(JSON.stringify({ error: "unauthenticated" }), { + status: 401, + }); + } + const usable = entitlements.usable ?? solutions.map((s) => s.id); + return new Response( + JSON.stringify({ + org: entitlements.org ?? "org-acme", + viewer: entitlements.viewer ?? "viewer-1", + solutions: usable.map((id) => ({ + 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 +105,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 +175,7 @@ describe("solutions surfaces route", () => { id: "audit", title: "AUDIT", origin: "https://audit.internal", + available: true, surfaces: [WORD_SURFACE], }, ], @@ -184,9 +246,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 +268,7 @@ describe("solutions surfaces route", () => { solutions: Array>; }; expect(Object.keys(parsed.solutions[0] ?? {}).sort()).toEqual([ + "available", "id", "origin", "surfaces", @@ -232,9 +295,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 +323,169 @@ describe("solutions surfaces route", () => { expect(res.status).toBe(503); expect(await res.json()).toEqual({ error: "registry_unavailable" }); }); + + // ----------------------------------------------------------------------- + // Per-organization, per-viewer narrowing (issue #949) + // ----------------------------------------------------------------------- + + const audit = { + id: "audit", + status: "active", + manifest: manifest("audit", [WORD_SURFACE]), + }; + const ledger = { + id: "ledger", + status: "active", + manifest: manifest("ledger", [WORD_SURFACE]), + }; + + it("answers 401, not an empty list, to an unauthenticated caller", async () => { + // [] would make "you are not signed in" and "your organization installed + // nothing" the same answer, and a client told the second audits its + // registration instead of its session. + vi.stubGlobal("fetch", gatewayServing([audit])); + const res = await GET(request("?client=word", "")); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "unauthenticated" }); + }); + + it("refuses before reading the registry, so it is no availability oracle", async () => { + // An unauthenticated caller must not be able to learn whether the registry + // is healthy by comparing 401 against 503. + const fetchMock = gatewayServing([audit]); + vi.stubGlobal("fetch", fetchMock); + await GET(request("?client=word", "")); + const paths = fetchMock.mock.calls.map( + (call) => + new URL( + call[0] instanceof Request + ? call[0].url + : String(call[0] as string | URL), + ).pathname, + ); + expect(paths).toEqual(["/solutions/_entitlements"]); + }); + + it("answers 403 when the session carries no organization", async () => { + vi.stubGlobal( + "fetch", + gatewayServing([audit], { status: 403, refusal: "no-organization" }), + ); + const res = await GET(request("?client=word")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "no_organization" }); + }); + + it("answers 403 forbidden, not no_organization, for an ext_authz refusal", async () => { + // Only the gateway's own named refusal means "no organization"; a bare 403 + // is a verdict on the user's credential and must not be relabelled. + vi.stubGlobal("fetch", gatewayServing([audit], { status: 403 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "forbidden" }); + }); + + it("answers 503, not 401, when the gateway refuses this frontend's internal credential", async () => { + // A deployment fault on the frontend's own credential. Relayed as 401 it + // would tell every signed-in viewer to re-authenticate, and make the menu's + // authedFetch rotate the refresh token on every poll. + vi.stubGlobal( + "fetch", + gatewayServing([audit], { status: 401, refusal: "internal-credential" }), + ); + const res = await GET(request("?client=word")); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "authority_unavailable" }); + }); + + it("relays 429 when the organization spent its read budget", async () => { + vi.stubGlobal("fetch", gatewayServing([audit], { status: 429 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: "rate_limited" }); + }); + + it("answers 503, never an empty list, when the authority cannot answer", async () => { + vi.stubGlobal("fetch", gatewayServing([audit], { status: 500 })); + const res = await GET(request("?client=word")); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: "authority_unavailable" }); + }); + + it("hides a deployed solution the organization has not installed", async () => { + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { usable: ["audit"] }), + ); + const body = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(body.solutions.map((entry) => entry.id)).toEqual(["audit"]); + }); + + it("gives two viewers in one organization different surface lists", async () => { + // The acceptance case: same deployment, same registered set, different + // grants — so the answer is a function of the viewer, not the deployment. + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { + viewer: "viewer-in-reviewers", + usable: ["audit"], + }), + ); + const first = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(first.solutions.map((entry) => entry.id)).toEqual(["audit"]); + + resetRegistryCache(); + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { + viewer: "viewer-in-clerks", + usable: ["ledger"], + }), + ); + const second = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(second.solutions.map((entry) => entry.id)).toEqual(["ledger"]); + }); + + it("narrows after a revocation without the registry changing", async () => { + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { usable: ["audit", "ledger"] }), + ); + const before = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(before.solutions.map((entry) => entry.id)).toEqual([ + "audit", + "ledger", + ]); + + // The grant on `ledger` is revoked. Only the authority answer changes — + // and because the projection cache is keyed on a digest of it, the cached + // menu cannot survive the grant that justified it. + vi.stubGlobal( + "fetch", + gatewayServing([audit, ledger], { usable: ["audit"] }), + ); + const after = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string }>; + }; + expect(after.solutions.map((entry) => entry.id)).toEqual(["audit"]); + }); + + it("keeps an unhealthy installation listed and marks it unavailable", async () => { + vi.stubGlobal("fetch", gatewayServing([audit], { unhealthy: ["audit"] })); + const body = (await (await GET(request("?client=word"))).json()) as { + solutions: Array<{ id: string; available: boolean }>; + }; + // Listed, because the org installed it and the viewer was granted it. + // Unavailable, because it must not be routed as though it were serving. + expect(body.solutions).toHaveLength(1); + expect(body.solutions[0]?.available).toBe(false); + }); }); diff --git a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts index b5785d0fc..0d9ad6231 100644 --- a/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts +++ b/module/services/frontend/code/src/app/api/solutions/surfaces/route.ts @@ -1,28 +1,48 @@ import { requestPublicOrigin } from "@/lib/public-origin"; import { - isClientKind, - loadSolutions, + entitlementFailureResponse, + isEntitlementFailure, + viewerEntitlements, +} from "@/solutions/entitlements"; +import { + cachedProjection, + entitledSolutions, surfacesProjection, +} from "@/solutions/projections"; +import { + isClientKind, + loadSolutionsWithRevision, + type SolutionClientSurfaces, } from "@/solutions/registry"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; /** - * The client-facing projection of what registered solutions offer inside one - * kind of client — a word processor, a spreadsheet — rather than inside - * this host's own pages. + * The client-facing projection of what the CALLING VIEWER may use inside one + * kind of client — a word processor, a spreadsheet — rather than inside this + * host's own pages. * * A client that cannot ask this has to ship a table of which solution offers * what, which goes stale the moment a solution is registered, withdrawn, or * changes what it offers. Asking for its own kind gets it exactly what applies * and nothing else. * - * Like the navigation projection beside it, this is deliberately ungated, and - * like it this carries no manifest path and no backend service. It does carry - * each solution's origin, because a surface module is a path the client fetches - * against that origin: withholding it would not keep the origin from a caller - * that can use a surface at all, only make the answer unusable. + * This route is authenticated (issue #949). It used to be deliberately ungated, + * and answered every caller the same deployment-wide set — so the list was a + * function of what is *registered*, not of what the caller may *use*. Narrowing it + * needs a verified organization and viewer, which exist only behind the gateway's + * `ext_authz`; see solutions/entitlements.ts for why nothing here may derive them. + * + * An unauthenticated caller therefore gets 401, not an empty list. `[]` would make + * "you are not signed in" and "your organization installed nothing" the same + * answer, and a client told the second goes looking at its registration instead of + * its session. + * + * It still carries each solution's origin, because a surface module is a path the + * client fetches against that origin: withholding it would not keep the origin + * from a caller that can use a surface at all, only make the answer unusable. The + * manifest path and backend service are still withheld. */ export async function GET(request: Request): Promise { // The kind is required, never defaulted to "all": a projection that widens @@ -39,7 +59,13 @@ export async function GET(request: Request): Promise { if (!isClientKind(client)) { return Response.json({ error: "invalid_client" }, { status: 400 }); } - const registered = await loadSolutions(); + // Asked before the registry is read: a caller with no standing to see any + // projection must not be able to probe whether the registry is healthy. + const entitlements = await viewerEntitlements(request); + if (isEntitlementFailure(entitlements)) { + return entitlementFailureResponse(entitlements); + } + const registered = await loadSolutionsWithRevision(); // An empty registry and an unreadable one must not render the same: the // first correctly offers nothing, the second would silently retract every // surface a client is currently showing. @@ -49,9 +75,22 @@ export async function GET(request: Request): Promise { // A solution served through this host resolves its modules against this // host's own public origin (see surfacesProjection). const hostOrigin = requestPublicOrigin(request); - return Response.json({ - solutions: registered - .map((manifest) => surfacesProjection(manifest, client, hostOrigin)) - .filter((projected) => projected !== null), - }); + // The origin is part of the projection, so it is part of what may be reused: + // two callers reaching this host through different public origins must not + // share a cached answer that resolves a module against one of them. + const solutions = cachedProjection( + entitlements, + `${client}\u0000${hostOrigin ?? ""}`, + registered.revision, + () => + entitledSolutions(registered.solutions, entitlements) + .map(({ manifest, entitlement }) => + surfacesProjection(manifest, client, entitlement, hostOrigin), + ) + .filter( + (projected): projected is SolutionClientSurfaces => + projected !== null, + ), + ); + return Response.json({ solutions }); } diff --git a/module/services/frontend/code/src/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..df5f78ada 100644 --- a/module/services/frontend/code/src/solutions/SolutionsMenu.tsx +++ b/module/services/frontend/code/src/solutions/SolutionsMenu.tsx @@ -3,9 +3,23 @@ import Link from "next/link"; import { useSyncExternalStore } from "react"; +import { decodeJWTPayload } from "@/lib/auth-session"; +import { + authedFetch, + getToken, + subscribeToken, +} from "@/lib/connect/token-store"; + interface SolutionNav { id: string; nav: { title: string; path: string; order?: number }; + /** + * False when the solution is installed and granted but its installation is + * not healthy. It stays in the menu, disabled: the organization did install it + * and this viewer was granted it, so hiding it would send someone looking for + * a grant that already exists. + */ + available?: boolean; } // Single shared poll loop for the registered-solutions list. Every mounted @@ -18,6 +32,62 @@ const EMPTY: SolutionNav[] = []; let snapshot: SolutionNav[] = EMPTY; const listeners = new Set(); let timer: ReturnType | null = null; +let stopWatchingToken: (() => void) | null = null; + +// Whose list `snapshot` is. The list is per viewer and per organization (#949), +// and it lives in this module, not in a component — so it outlives an unmount, a +// sign-out and the next person signing in on the same tab. Without an owner, the +// next viewer is shown the previous viewer's solutions until their own first +// poll succeeds, and indefinitely if it never does. +let snapshotViewer: string | null = null; + +/** + * The viewer the current token speaks for: the effective subject (the + * impersonated user during an impersonation, as the server narrows on) in its + * organization, or null when signed out. + * + * This reads the token's claims without verifying them, which is deliberate and + * safe HERE: the answer only decides whether to throw away a client-side copy of a + * list, never what anyone may see — the server narrows on the identity the gateway + * verified. It is the same presentational use lib/auth-session.ts's + * resolveSessionUser makes of the claims. + */ +function currentViewer(): string | null { + const token = getToken(); + if (!token) return null; + const claims = decodeJWTPayload(token); + const subject = + typeof claims.acting === "string" && claims.acting + ? claims.acting + : typeof claims.sub === "string" + ? claims.sub + : ""; + const org = typeof claims.org === "string" ? claims.org : ""; + return `${subject}\u0000${org}`; +} + +function publish(next: SolutionNav[]): void { + // Keep the reference stable when nothing changed so subscribers don't + // re-render on every poll (useSyncExternalStore compares by identity). + if (sameList(snapshot, next)) return; + snapshot = next; + for (const listener of listeners) { + listener(); + } +} + +/** + * Drop the list when it belongs to someone else. A routine token refresh keeps the + * same viewer, so it changes nothing and the menu does not flicker; a sign-out, a + * different person, or a switch of organization empties it at once. + */ +function forgetIfViewerChanged(): boolean { + const viewer = currentViewer(); + if (viewer === snapshotViewer) return false; + snapshotViewer = viewer; + publish(EMPTY); + return true; +} function sameList(a: SolutionNav[], b: SolutionNav[]): boolean { if (a === b) return true; @@ -27,33 +97,48 @@ function sameList(a: SolutionNav[], b: SolutionNav[]): boolean { return ( item.id === other.id && item.nav.title === other.nav.title && - item.nav.path === other.nav.path + item.nav.path === other.nav.path && + // Availability is rendered, so a change in it must re-render. Leaving it + // out of the comparison would freeze a solution's disabled state for the + // life of the page. + item.available === other.available ); }); } async function refresh(): Promise { + forgetIfViewerChanged(); + const viewer = snapshotViewer; + if (viewer === null) return; try { - const response = await fetch("/api/solutions/register", { + // The menu is this viewer's own (#949), so the listing is authenticated and + // answers what THIS viewer may use. authedFetch attaches the host's bearer + // and, on a lapsed access token, exchanges it once and retries — the same + // recovery every other authenticated call gets. A bare fetch would 401 on + // every poll after the access token aged out and empty a working menu. + const response = await authedFetch("/api/solutions/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) { - // The listing answers 503 when this replica cannot read the registry. - // Treating that as "no solutions" would empty a working nav on a - // blip: an unreadable registry is not an empty one, which is the - // whole reason the route distinguishes them. + // Every other non-OK answer keeps this viewer's last known list. 503 is + // an unreadable registry or an authority that could not answer; 429 is + // the organization's read budget. Treating any of them as "no solutions" + // would empty a working nav on a blip — the reason the route + // distinguishes them from an empty projection at all. return; } const data: { solutions?: SolutionNav[] } = await response.json(); - const next = data.solutions ?? []; - // Keep the reference stable when nothing changed so subscribers don't - // re-render on every poll (useSyncExternalStore compares by identity). - if (!sameList(snapshot, next)) { - snapshot = next; - for (const listener of listeners) { - listener(); - } - } + if (currentViewer() !== viewer) return; + publish(data.solutions ?? []); } catch { // Network blip — keep the last known list. } @@ -64,18 +149,31 @@ function subscribe(listener: Listener): () => void { if (listeners.size === 1) { void refresh(); timer = setInterval(refresh, 10_000); + // Re-check ownership the moment the token changes rather than on the next + // tick, so a new viewer never waits up to one poll with someone else's list. + stopWatchingToken = subscribeToken(() => { + if (forgetIfViewerChanged()) void refresh(); + }); } return () => { listeners.delete(listener); - if (listeners.size === 0 && timer) { - clearInterval(timer); - timer = null; + if (listeners.size === 0) { + if (timer) { + clearInterval(timer); + timer = null; + } + stopWatchingToken?.(); + stopWatchingToken = null; } }; } function getSnapshot(): SolutionNav[] { - return snapshot; + // React reads this during render, before subscribe has run: on a remount after + // a sign-out or a switch, returning `snapshot` as-is would paint the previous + // viewer's list for that render. EMPTY is a constant, so the answer stays + // stable for useSyncExternalStore until refresh publishes this viewer's own. + return currentViewer() === snapshotViewer ? snapshot : EMPTY; } /** @@ -96,30 +194,56 @@ export function SolutionsMenu({ variant = "list" }: { variant?: "list" | "cards" if (variant === "cards") { return (
- {solutions.map((solution) => ( - -
{solution.nav.title}
-
{solution.id}
- - ))} + {solutions.map((solution) => + solution.available === false ? ( + // Rendered, not linked. The grant exists, so the entry belongs + // here; the installation does not currently serve, so following + // it would fail after a navigation rather than before one. +
+
{solution.nav.title}
+
Unavailable
+
+ ) : ( + +
{solution.nav.title}
+
{solution.id}
+ + ), + )}
); } return ( ); } diff --git a/module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx b/module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx new file mode 100644 index 000000000..0ce0afd97 --- /dev/null +++ b/module/services/frontend/code/src/solutions/__tests__/SolutionsMenu.test.tsx @@ -0,0 +1,158 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The menu's store is module state, so each test loads a fresh copy of the module +// with its own token and fetch stubs. +const tokenState = vi.hoisted(() => ({ + token: null as string | null, + fetch: vi.fn<(input: string) => Promise>(), +})); + +vi.mock("@/lib/connect/token-store", () => ({ + getToken: () => tokenState.token, + authedFetch: (input: string) => tokenState.fetch(input), + subscribeToken: (listener: () => void) => { + window.addEventListener("codefly:auth-changed", listener); + return () => window.removeEventListener("codefly:auth-changed", listener); + }, +})); + +function token(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims), "utf8").toString( + "base64url", + ); + return `header.${payload}.signature`; +} + +function signIn(claims: Record) { + tokenState.token = token(claims); + window.dispatchEvent(new Event("codefly:auth-changed")); +} + +function listing(...ids: string[]): Response { + return Response.json({ + solutions: ids.map((id) => ({ + id, + nav: { title: id.toUpperCase(), path: `/s/${id}` }, + available: true, + })), + }); +} + +async function mountMenu() { + const { SolutionsMenu } = await import("@/solutions/SolutionsMenu"); + return render(); +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +const ALICE = { sub: "user-alice", org: "org-acme" }; +const BOB = { sub: "user-bob", org: "org-acme" }; + +describe("SolutionsMenu ownership (#949)", () => { + beforeEach(() => { + vi.resetModules(); + tokenState.token = token(ALICE); + tokenState.fetch.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("does not show the previous viewer's list to the next one", async () => { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await settle(); + expect(screen.queryByText("AUDIT")).not.toBeNull(); + + // Bob signs in on the same tab and his first poll cannot be answered. + // Keeping "the last known list" would keep ALICE's. + tokenState.fetch.mockResolvedValue( + Response.json({ error: "authority_unavailable" }, { status: 503 }), + ); + await act(async () => signIn(BOB)); + await settle(); + expect(screen.queryByText("AUDIT")).toBeNull(); + }); + + it("discards a response that was started for a viewer who has since left", async () => { + let answerAlice: (response: Response) => void = () => {}; + tokenState.fetch.mockReturnValueOnce( + new Promise((resolve) => { + answerAlice = resolve; + }), + ); + await mountMenu(); + + tokenState.fetch.mockResolvedValue(listing("ledger")); + await act(async () => signIn(BOB)); + await settle(); + // Alice's slow answer lands after the switch; it must not paint over Bob's. + await act(async () => answerAlice(listing("audit"))); + await settle(); + expect(screen.queryByText("AUDIT")).toBeNull(); + expect(screen.queryByText("LEDGER")).not.toBeNull(); + }); + + it("clears the list on a 401 the transport could not recover", async () => { + vi.useFakeTimers(); + try { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.queryByText("AUDIT")).not.toBeNull(); + + // authedFetch already tried to exchange the session and could not, so + // the viewer is signed out. Their list must not stay on screen. + tokenState.fetch.mockResolvedValue( + Response.json({ error: "unauthenticated" }, { status: 401 }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(tokenState.fetch).toHaveBeenCalledTimes(2); + expect(screen.queryByText("AUDIT")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the list through a 503 for the same viewer", async () => { + vi.useFakeTimers(); + try { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + tokenState.fetch.mockResolvedValue( + Response.json({ error: "authority_unavailable" }, { status: 503 }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + // An outage is not a sign-out: this viewer keeps their own list. + expect(screen.queryByText("AUDIT")).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the list across a routine token refresh for the same viewer", async () => { + tokenState.fetch.mockResolvedValueOnce(listing("audit")); + await mountMenu(); + await settle(); + + await act(async () => signIn({ ...ALICE, jti: "rotated" })); + await settle(); + expect(screen.queryByText("AUDIT")).not.toBeNull(); + expect(tokenState.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/module/services/frontend/code/src/solutions/__tests__/registry.test.ts b/module/services/frontend/code/src/solutions/__tests__/registry.test.ts index 6c6bd290d..956cb7753 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,35 @@ const { getEndpoints, getWorkspaceSecret } = vi.hoisted(() => ({ })); vi.mock("codefly", () => ({ getEndpoints, getWorkspaceSecret })); +import type { + SolutionEntitlement, + ViewerEntitlements, +} from "@/solutions/entitlements"; +import { + cachedProjection, + entitledSolutions, + invalidateProjections, + navProjection, + surfacesProjection, +} from "@/solutions/projections"; import { browserManifestUrl, findSolution, loadSolutions, 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 +400,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 +422,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 +448,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 +854,214 @@ 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); + }); +}); + +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 new file mode 100644 index 000000000..a43ad9e19 --- /dev/null +++ b/module/services/frontend/code/src/solutions/entitlements.ts @@ -0,0 +1,302 @@ +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" + | "no_organization" + | "forbidden" + | "rate_limited" + | "unavailable"; + +const GATEWAY_ENTITLEMENTS_PATH = "/solutions/_entitlements"; +const INTERNAL_TOKEN_HEADER = "X-Codefly-Internal-Token"; + +/** + * The header the gateway uses to name its OWN refusals on this endpoint, and the + * values it sends (gateway_solution_entitlements.go). Status alone cannot + * separate them: a refused cluster-internal token and a refused user bearer are + * both 401, and reading the first as the second would tell every signed-in user + * to re-authenticate over a deployment fault — and drive a refresh-token rotation + * on every menu poll. + */ +const REFUSAL_HEADER = "X-Codefly-Entitlement-Refusal"; +const REFUSAL_INTERNAL_CREDENTIAL = "internal-credential"; +const REFUSAL_NO_ORGANIZATION = "no-organization"; + +/** + * How long the gateway gets to answer. It asks accounts under its own 10s budget, + * so this is a little longer; the point is that it is finite. A viewer's menu + * blocks on it, so a stalled authority must become a failed projection rather + * than a hung page. + */ +const ENTITLEMENTS_READ_TIMEOUT_MS = 12_000; + +/** + * The auth-gateway origin, from Codefly service discovery. It must NOT come from + * the incoming request's Host header: routing a server-side fetch through a + * client-controlled host is an SSRF sink. Same rule, same reason, as the registry + * snapshot read in registry.ts. + */ +function gatewayOrigin(): string | null { + const endpoint = getEndpoints().find( + (candidate) => + candidate.service === "auth-gateway" && candidate.name === "rest", + ); + if (!endpoint?.address) return null; + try { + return new URL(endpoint.address).origin; + } catch { + return null; + } +} + +function internalToken(): string | null { + const token = getWorkspaceSecret( + "internal-auth", + "CODEFLY_INTERNAL_TOKEN", + )?.trim(); + return token ? token : null; +} + +interface GatewayEntitlementEntry { + 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"; + } + const refusal = response.headers.get(REFUSAL_HEADER); + if (refusal === REFUSAL_INTERNAL_CREDENTIAL) { + // This process's own credential, not the viewer's. An outage of this + // replica, answered 503 — never relayed as the user being signed out. + console.error( + "solution entitlements: the gateway refused this frontend's internal credential", + ); + return "unavailable"; + } + if (response.status === 401) return "unauthenticated"; + // Authenticated, but with no organization to answer for. Distinct from 401: + // re-authenticating would not change it, so a consumer must not be sent back + // through login. + if (refusal === REFUSAL_NO_ORGANIZATION) return "no_organization"; + // A 403 verdict from ext_authz on the user's credential. + if (response.status === 403) return "forbidden"; + // The viewer's organization spent its read budget. Relayed as 429 so a client + // backs off, rather than as an outage it would retry immediately. + if (response.status === 429) return "rate_limited"; + if (!response.ok) { + console.error(`solution entitlements: gateway answered ${response.status}`); + return "unavailable"; + } + const parsed = parseEntitlements(await response.json().catch(() => null)); + if (parsed === null) { + console.error("solution entitlements: malformed gateway answer"); + return "unavailable"; + } + return parsed; +} + +/** + * The response a projection route answers for a failed entitlement read — one + * mapping, so the two routes cannot drift into describing the same failure + * differently. None of these is an empty list: each is a reason the caller acts on + * differently (sign in, pick an organization, back off, retry later). + */ +export function entitlementFailureResponse( + failure: EntitlementFailure, +): Response { + switch (failure) { + case "unauthenticated": + return Response.json({ error: "unauthenticated" }, { status: 401 }); + case "no_organization": + return Response.json({ error: "no_organization" }, { status: 403 }); + case "forbidden": + return Response.json({ error: "forbidden" }, { status: 403 }); + case "rate_limited": + return Response.json({ error: "rate_limited" }, { status: 429 }); + case "unavailable": + return Response.json({ error: "authority_unavailable" }, { status: 503 }); + } +} + +/** Narrows a read's result to its failure, for the routes' early return. */ +export function isEntitlementFailure( + result: ViewerEntitlements | EntitlementFailure, +): result is EntitlementFailure { + return typeof result === "string"; +} diff --git a/module/services/frontend/code/src/solutions/projections.ts b/module/services/frontend/code/src/solutions/projections.ts new file mode 100644 index 000000000..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 40bc1b453..bd4cd7696 100644 --- a/module/services/frontend/code/src/solutions/registry.ts +++ b/module/services/frontend/code/src/solutions/registry.ts @@ -101,18 +101,33 @@ export interface SolutionSurface { events?: string[]; } -/** The public navigation projection (see navProjection). */ -export type SolutionNav = Pick; +/** The per-viewer navigation projection (see navProjection in projections.ts). */ +export type SolutionNav = Pick & { + /** + * False when the solution is installed and granted but its installation is + * not healthy right now — an offboarded owner of record, a revoked or disabled + * agent, a standing grant that lapsed. + * + * Such a solution stays IN the projection. The organization did install it and + * the viewer was granted it, so dropping it would send someone looking for a + * grant that already exists; what must not happen is routing it as though it + * were serving. A consumer renders it disabled and says why it cannot be + * opened. + */ + available: boolean; +}; /** The internal detail projection (see detailProjection). */ export type SolutionDetail = Omit; -/** The per-client surface projection (see surfacesProjection). */ +/** The per-client surface projection (see surfacesProjection in projections.ts). */ export interface SolutionClientSurfaces { id: string; title: string; /** Origin a surface's `module` path is resolved against. */ origin: string; + /** See SolutionNav.available — the same distinction, for a non-host client. */ + available: boolean; surfaces: SolutionSurface[]; } @@ -132,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 @@ -694,6 +718,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, @@ -703,79 +746,6 @@ export async function findSolution( return current.byId.get(id) ?? null; } -/** - * What every signed-in browser may read: the id and the nav entry the Solutions - * menu renders. A manifest also carries deployment topology — the origin the - * solution's code is served from, the backend service that fronts it, and its - * dashboard declaration — which no browser needs to render a link, so the - * public listing projects it away rather than shipping it to every poll. - */ -export function navProjection(manifest: SolutionManifest): SolutionNav { - return { id: manifest.id, nav: { ...manifest.nav } }; -} - -/** - * What one kind of client may read: the surfaces declared for that kind, named - * by the solution offering them. A client asks for its own kind and gets - * exactly what applies to it, so learning what is on offer no longer means - * shipping a table of who offers what inside every client. - * - * Solutions are registered deployment-wide, not per tenant, so every caller - * sees the same set today. When per-tenant enablement exists it narrows this - * projection, and every client inherits the narrowing without changing. - * - * Returns null when this solution declares nothing for that client, which is - * how the listing leaves it out rather than listing it empty. - */ -export function surfacesProjection( - manifest: SolutionManifest, - client: string, - hostOrigin?: string, -): SolutionClientSurfaces | null { - const surfaces = (manifest.surfaces ?? []).filter( - (surface) => surface.client === client, - ); - if (surfaces.length === 0) { - return null; - } - // A solution served through this host has no origin of its own a client can - // reach: its modules are paths on its backend, which this host serves under - // the solution's proxy base. Without the host's own origin there is nothing - // to resolve them against, so the solution is left out rather than answered - // with an address that cannot work. - const servedByHost = manifest.frontend.manifestUrl.startsWith("/"); - if (servedByHost && !hostOrigin) { - return null; - } - const modulePath = (module: string) => - servedByHost ? `${solutionProxyBase(manifest.id)}${module}` : module; - return { - id: manifest.id, - title: manifest.nav.title, - // A declared module is a path on the solution's origin, so without the - // origin no caller can fetch one. The origin is not withheld topology - // here the way the manifest path is: the client fetches the module from - // it directly, and every signed-in document already carries it in the - // CSP that admits the same origin's code. - origin: servedByHost - ? (hostOrigin as string) - : new URL(manifest.frontend.manifestUrl).origin, - // A shallow copy would share `applies.tagged` and `events` with the - // cached snapshot, which outlives this response and is read by every - // later caller — including other client kinds, which read the same - // manifest objects. - surfaces: surfaces.map((surface) => ({ - ...surface, - module: modulePath(surface.module), - applies: - typeof surface.applies === "object" - ? { tagged: [...surface.applies.tagged] } - : surface.applies, - events: surface.events === undefined ? undefined : [...surface.events], - })), - }; -} - /** * What a caller holding the cluster-internal token may read: everything needed * to resolve the remote and its backend. The dashboard graph is left out — it 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..b90d872f5 100644 --- a/module/tools/solution_registration_boundary_test.go +++ b/module/tools/solution_registration_boundary_test.go @@ -8,29 +8,51 @@ 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. +// +// 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/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 +127,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 +138,77 @@ func TestSolutionRegistrationBoundaryIsDocumented(t *testing.T) { } } +// TestSolutionProjectionsNarrowByInstallation is the inverse of the scan above: +// these files MUST consult the per-viewer entitlement read. +// +// It exists because the regression it catches is invisible. A projection that +// stopped narrowing would not throw, would not fail a type check and would return +// a perfectly well-formed list — just the deployment-wide one, to every viewer. +// Asserting the coupling is present is the only way that shows up as a failure. +func TestSolutionProjectionsNarrowByInstallation(t *testing.T) { + moduleDir := findModuleDir(t) + for _, relative := range projectionSurfaces { + data, err := os.ReadFile(filepath.Join(moduleDir, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + code := codeOnly(string(data)) + // The entitlement read, and the narrowing that consumes it. This is a + // presence check and proves only that: a route that imported both and then + // projected the registered set anyway would still pass here. That behaviour + // is caught by the route tests (a deployed but uninstalled solution must be + // absent from each projection); this test catches the coarser regression of + // a projection route dropping the narrowing entirely. + for _, identifier := range []string{ + "viewerEntitlements", + "entitledSolutions", + } { + if !strings.Contains(code, identifier) { + t.Errorf( + "%s does not reference %q: a solution projection must narrow through installations and the viewer's grants (SOLUTION_REGISTRATION.md §4, issue #949), never answer the deployment-wide set", + relative, identifier, + ) + } + } + } +} + +// TestSolutionProjectionsDoNotDeriveIdentityLocally keeps the projections off the +// one shortcut that would make the narrowing worthless. +// +// `lib/auth-session.ts` can read an organization out of an access token, but +// `decodeJWTPayload` only base64-decodes it — it verifies nothing. A projection +// that narrowed on that would let any caller read another tenant's menu by editing +// one claim, and it would pass every other test in this file: it consults +// installation state, it narrows, and it is wrong. +func TestSolutionProjectionsDoNotDeriveIdentityLocally(t *testing.T) { + moduleDir := findModuleDir(t) + surfaces := append([]string{ + "services/frontend/code/src/solutions/entitlements.ts", + "services/frontend/code/src/solutions/projections.ts", + "services/frontend/code/src/solutions/registry.ts", + }, projectionSurfaces...) + for _, relative := range surfaces { + data, err := os.ReadFile(filepath.Join(moduleDir, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + code := codeOnly(string(data)) + for _, forbidden := range []string{ + "extractSessionContext", + "decodeJWTPayload", + "extractRoles", + } { + if strings.Contains(code, forbidden) { + t.Errorf( + "%s references %q: it decodes an access token without verifying it, so an organization read from it is one the CALLER chose. The verified tenant and viewer come from the gateway's ext_authz stamp (services/frontend/code/src/solutions/entitlements.ts)", + relative, forbidden, + ) + } + } + } +} + // TestSolutionAndModuleRegistrationCredentialsAreSeparate keeps the two // registrant kinds from collapsing into one grant. A solution remote executes in // the host origin with the viewer's credentials; a module federates a REST