Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion module/DEPLOYMENT_TOPOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 86 additions & 7 deletions module/SOLUTION_REGISTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<kind>` (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

Expand All @@ -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.
Binary file modified module/contracts/api/accounts/authority/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
};
}
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
};
}
}
Binary file modified module/contracts/api/accounts/connect/contract.binpb
Binary file not shown.
Loading
Loading