diff --git a/docker/keycloak/realm-studio.json b/docker/keycloak/realm-studio.json index b0e1f51d..1dab493d 100644 --- a/docker/keycloak/realm-studio.json +++ b/docker/keycloak/realm-studio.json @@ -6,6 +6,7 @@ "accessTokenLifespan": 3600, "users": [ { + "id": "00000000-0000-4000-8000-000000000a11", "username": "admin", "enabled": true, "email": "admin@studio.local", @@ -26,6 +27,7 @@ } }, { + "id": "00000000-0000-4000-8000-000000000de0", "username": "demo", "enabled": true, "email": "demo@studio.local", diff --git a/docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md b/docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md new file mode 100644 index 00000000..d81ac2d8 --- /dev/null +++ b/docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md @@ -0,0 +1,381 @@ +# ADR-0018: An identity proves it is you; the person decides everything else + +Status: **proposed** · Date: 2026-09-11 · Supersedes ADR-0011 §3 and §4 · Extends ADR-0014 + +## Context + +ADR-0006 gave Studio a canonical person, ADR-0014 made it reachable from any +gear, ADR-0015 confirmed brokered logins onto it, ADR-0016 gave membership a +writer and a reader. Each of those fixed one consumer that had been keyed on the +sign-in method instead of the human. + +Mapping the subsystem afterwards showed the pattern was not a series of +oversights but a missing rule. Three examples, all live: + +- **Settings.** The platform's settings gear files a row under + `(ctx.subject_id(), ctx.subject_tenant_id())`. Its field is called `user_id` + and it holds an identity. The name is what hid the defect: nobody noticed that + *user* and *identity* had come apart, so a person signing in the other way + silently lost their theme. ADR-0017 took the gear over. +- **Administrative rights.** `config/oidc.yaml` maps the Keycloak user attribute + `tenant_id` into `subject_tenant_id`, and three comparisons against the + platform root — guarding eight administrative routes between them — decide + from it whether the caller is a platform administrator. The + attribute belongs to a Keycloak user — that is, to a *login*. A person with + two logins can be a platform administrator through one and an ordinary member + through the other. +- **Which organizations you see.** Same claim, same consequence, on the portal's + organization list. + +That last pair is the sharp end: **rights that depend on which way you signed +in.** Nobody decided that; it is what happens when no rule says otherwise. + +A second question arrived with it. Studio ships two ways — a cloud service where +people arrive with no organization, and an installation inside one company where +exactly one organization exists. The obvious move is a deployment mode flag with +branches behind it, and the obvious move is wrong: it doubles the state space of +every screen and every authorization path, and one of the two branches ends up +being the untested one. + +## Decision + +### 1. The rule + +**An identity establishes that it is you. Everything you can see and do follows +from the person.** + +Three layers, with owners that do not overlap: + +| Layer | Owns | +|---|---| +| **Identity** (a login) | the provider, credentials, whether it is verified, when it was last used. Facts about *signing in* — and no product state. | +| **Person** (a user) | settings, profile, attributions, authorship. Everything the product is about. | +| **Membership** | `(person, organization) → role`. The only source of what a person can reach. | + +The rule is not "identities own nothing". A security audit has to be able to say +*how* an action arrived, and that is an identity's fact. The line is that +product state hangs off the person, never off the way they got here. + +This makes the earlier ADRs one thing rather than four: they were each moving a +consumer across that line. + +### 2. Onboarding: a person arrives with no organization, and that is normal + +Three states, none of which depends on how somebody signed in: + +1. **No organization.** Create your own, or accept an invitation. +2. **Owner of their own.** They created it; they assign roles in it. +3. **Member of somebody else's.** By invitation; their role came from the owner. + +**Creating an organization is self-service.** A person who can sign in can +create one and becomes its owner. In the cloud they may create as many as they +need — a personal one, a work one, one per client is a normal shape and the +organization switcher already handles it. + +**Invitations ship with creation, not after it.** An owner who cannot invite +anyone has built a product for one. This also retires the dead end the +no-membership screen currently points at: it tells people to ask an +administrator, and there is no mechanism behind that sentence. + +ADR-0011 §6 — single-use expiring invitation tokens bound to a verified +identity — stands unchanged. It stops being the *only* way in and becomes the +second one. + +**The platform administrator resolves conflicts.** Enumerated, because an +unenumerated administrator becomes the answer to everything: + +- take ownership of an organization whose last owner is gone; +- merge two persons who turn out to be one human; +- settle an external identity that two people claim (ADR-0012 follow-up 3); +- suspend an organization. + +And explicitly **not**: create organizations for people, or appoint the owner of +every organization. Nobody's first day waits on an administrator. + +The role is **per installation, not per vendor**. In the cloud it is us; in a +company's own installation it is their IT. The bootstrap that already seeds the +platform root seeds the first administrator with it. + +### 3. Administrative rights become a property of the person + +Platform administrator stops meaning "this token's tenant is the root" and +starts meaning **"this person holds a membership of the platform root"**. + +The data already exists: ADR-0016's backfill wrote exactly that row for every +identity whose `tenant_id` attribute named the root. This also removes the last +reader of that attribute in an organization sense, which is what ADR-0016's +first follow-up was blocked on. + +Migration, in the order that never leaves a gap — the same shape used for the +organization list: + +1. the installation bootstrap seeds a root membership for the configured first + administrator; +2. the server accepts **either** signal — a root membership or the token's + tenant; +3. the token reading is deleted, and with it the `tenant_id` attribute's last + purpose. + +### 4. One product, two provisioning profiles + +The domain model does not differ between cloud and a single-company +installation. Person, login, membership, role are identical, and so is every +authorization path. What differs is **how a person acquires their first +organization** — a provisioning policy, not a mode. + +```yaml +organizations: + self_service: true # may a person create one? + on_first_login: none # or: join(, ) +``` + +- **Cloud:** `self_service: true`, `on_first_login: none`. +- **Inside one company:** `self_service: false`, + `on_first_login: join(the_organization, member)`. + +**Whether the organization switcher appears is derived from data, not +configured**: a person with one organization has nothing to switch to. One fewer +flag is one fewer untested path. + +#### Automatic membership is not access derived from authentication + +In a single-company installation, the first successful login creates a +membership of the one organization. That looks like the thing ADR-0011 §1 +forbids, and it is not, for a reason worth stating precisely: + +- an **auto-provisioned membership** is a row. It can be revoked — suspending a + person in Studio without removing them from the corporate directory, which is + frequently what the company actually wants. It is auditable: `membership.source` + records that it came from a first login rather than from an invitation or an + operator. Roles come from it. +- **access derived from authentication** follows the token, forever, and cannot + be taken away short of deleting the account. + +The deployment is making a statement — *the users of this identity provider are +the members of this organization* — and the statement is recorded as data. +ADR-0011 §1 stands: authentication still grants nothing by itself. + +This matters more than it looks: today's single-company story rests on the +`tenant_id` attribute giving everyone the same home tenant. That attribute is +what §3 above deletes. Auto-provisioned membership is its replacement, and a +strictly better one, because an attribute cannot be revoked and a row can. + +### 5. An organization has a name, not an address + +Three things hide behind "organization name", and only one of them is in +question: + +- the **id** — the account-management tenant uuid. It is already what the portal + puts in its URLs. Not in question. +- the **display name** — "Constructor Fabric". Human-facing, freely editable. +- a **handle** — `…/acme/…` in a URL. If one exists it must be unique and + *stable*. + +**There is no handle.** Display names are free text and are not unique. + +The case for a handle is real — readable URLs, links that survive being pasted +into a ticket, invitations that look trustworthy. The case against is that a +global namespace brings squatting, disputes, a reserved-word list and +confusables (`асme` with a Cyrillic а is a different string and the same +picture), and that a handle in a URL is a permanent commitment: renaming then +breaks links and needs a redirect history. + +The asymmetry decides it. **A handle can be added later** — derived from the +names that exist, disambiguated where they collide. **It cannot be removed +later**, because by then it is in people's links. And the thing that usually +forces one, publicly shared URLs, does not exist here yet. + +Two guards are worth having from the start, and only two: trim surrounding +whitespace and cap the length; and when a person creates an organization whose +name matches one they already belong to, **warn rather than refuse** — two +organizations called "Acme" is their business, and a refusal would be us +pretending to know better. + +In a single-company installation the question does not arise: one organization, +named in configuration. + +### 6. Leaving removes access, and takes the leaver's credentials with it + +"Leaving" hides five different acts. Separated: + +1. **A member leaves.** Always allowed; their membership row goes. +2. **An owner removes a member.** ADR-0011 §5, unchanged. +3. **The last owner leaves.** Refused until ownership transfers. The last-owner + invariant from ADR-0011 §4 stands, and now has a self-service path into it — + so **an owner may appoint another owner in their own organization**. (That + sentence of §4 is superseded with the rest of it: without it, transfer would + require an administrator and every departure would become a support ticket.) +4. **The last person leaves.** Leaving *is* deleting, said plainly and + confirmed: "you are the only person here — leaving deletes this + organization". The alternative is ownerless organizations accumulating for an + administrator to sweep, which is the manual work this ADR exists to remove. +5. **Everybody is gone, or the owner vanished.** The platform administrator's + break-glass, per §2. + +**Access goes; authorship does not.** Documents, projects and workspaces belong +to the organization and stay. Attribution in the knowledge graph stays with the +person who earned it. History is not rewritten because somebody left. + +**But the leaver's credentials go with them.** This is not a refinement — it is +a leak in what exists today. `remove_membership` deletes one row and does +nothing else, while a *personal* connection the leaver created lives in the +organization's catalogue with their token in credstore. Today they leave and the +token stays, working, in an organization they are no longer part of. Under +ADR-0012 that same record is their proof of control over the external account, +so the organization also keeps holding their credential in the evidentiary +sense. + +So leaving deletes the personal connections that person created in that +organization, and their secrets with them. The organization loses a working +integration and has to create its own — which is the correct outcome, because it +never owned that one. + +**Suspension is not leaving.** An owner suspending somebody is a different act +with a different result: access stops, the row stays. `identity_membership` has +no `status` column today, though ADR-0011 §2 described one +(`invited | active | suspended | revoked`). Deleting the row is the right +implementation of *leaving*; suspension needs the status and is a separate +piece of work, gated with the rest of membership management by §7. + +## What this changes in ADR-0011 + +**Superseded:** + +- **§3** — "It must not expose … organization creation controls." The + no-organization screen now offers exactly that. +- **§4** — "A platform administrator … may also create additional organizations + and appoint an owner for each one", and "Only a platform administrator can + appoint, replace, or revoke an organization owner." Ownership arises from + creating an organization, and an owner may appoint another owner in their own + organization — without which no owner could ever hand over and leave (§6). +- **§4's bootstrapped default organization** goes with them. The bootstrap seeds + the platform root and the first administrator; people create their own. (A + consequence of the model rather than a separate decision — flagged as such + because it removes something a deployment may be relying on.) + +**Unchanged, and load-bearing:** + +- **§1** — authentication does not grant organization membership. Self-service + creation does not make anybody a member of anybody else's organization, and + §4's sentence "Authentication alone can never produce ownership or membership" + survives in substance: it is the *act of creating* that produces ownership. +- **§2** — explicit membership is the authority for organization access. +- **§6** — invitations. +- **§7** — membership and roles are enforced server-side before a + membership-management UI ships. Still the gate, and still not met: + `privilege_for` returns `None` unconditionally, so the PDP's grant branch + remains unreachable. + +## Consequences + +- (+) One rule replaces a pattern of individual defects, and says where the next + piece of state belongs without another argument. +- (+) The no-organization state stops being a waiting room. Nobody's first day + depends on an operator. +- (+) Cloud and single-company stop being two products. Two settings and one + derived UI rule cover the difference. +- (+) `membership.source` acquires its first real reader: an owner's member list + can show how each person got in. +- (−) Creating an organization is three writes across two systems — the AM + tenant, the owner's membership, and the access-config grant the PDP reads. It + must be one server-side operation and idempotent on retry, or a failure leaves + an organization nobody owns. Today the portal calls account-management's + `createTenant` directly from the browser, which cannot do this. +- (−) In a single-company installation the no-organization state becomes + unreachable on the happy path, so it risks being untested exactly where most + customers run. It stays reachable through a revoked or suspended membership, + and must be tested there. +- (−) Auto-join means a new hire reaches Studio the moment IT adds them to the + directory. That is the intent, and it means membership grows without anyone in + Studio acting — which is why the owner's member list showing `source` is part + of the deal, not a nicety. +- (−) Leaving has to delete the leaver's personal connections and secrets, which + nothing does today — `remove_membership` deletes one row and stops. Until that + lands, "leave" would look done while the credential stayed behind. +- (−) Self-service creation is safe while admission is gated (today: active + members of the `constructorfabric` GitHub organization). If sign-up ever + opens, a per-person quota becomes a precondition, not an improvement. + +## Follow-ups + +Numbered in the order they were built, which is the order each one unblocked the +next. Eight of the nine have shipped; what each note says is what it turned out +to mean once it was built, because several of them meant more than they looked +like from here. + +1. **The tenant clamp comes from membership, not from the token.** Moved to the + front by what building §2's create operation found: the PDP clamps every + request to the subtree of `subject_tenant_id`, the home tenant of the + *login*, so a person who creates an organization under the platform root + cannot then read or administer it — the tenant and the membership are + written and the owner grant fails with "tenant not found". Administrative + rights were the visible half of the rule; the clamp is the other half, and + nothing self-service works until it moves. **Shipped.** +2. **One server-side "create my organization" operation**, idempotent, writing + the tenant, the owner membership and the owner grant together. Built and + verified; it cannot be released to ordinary people before (1). **Shipped**, + and since reached from the portal: the no-organization screen offers creating + one and accepting an invitation waiting for you, and asks this installation + which of the two it allows before offering either. Accepting from that screen + needed one change the backend had not anticipated — the token is never + stored, so the portal had nothing to send, and an acceptance now takes the + invitation's id as well. It is no weaker: that listing exists because the + server matched the invitation to an address the person has proven. +3. **Invitations** (ADR-0011 §6), shipped with it — and, like it, gated on (1). + **Shipped.** +4. **Platform administrator as a root membership**, in the three migration steps + of §3 above. **Shipped.** +5. **`on_first_login` provisioning** for the single-company profile. **Shipped.** +6. **Leaving**, with the credential cleanup of §6 in the same operation, and + owner-to-owner transfer so the last owner can hand over. **Shipped.** The + last-owner rule turned out to belong in one place rather than on the leaving + route: removal, demotion and later suspension can each end the last + ownership, so all of them ask one question about the room as it would be + afterwards. `remove_membership` was deleted rather than left beside it — a + second door that skipped the rule and left the credentials behind is what the + piece existed to close. The other end of §6.4 followed: deleting an + organization undoes creating one in reverse — every membership and every + member's personal connections first, the tenant last, because the catalogue + holding those connections lives inside it. +7. **Retire the `tenant_id` attribute** once §3 lands — the last thing keeping + ADR-0016's follow-up open. **Shipped.** Two gates read the token, and with + both readings gone the compiler found that the platform-root constant in each + file had no other user, which is the cleanest evidence the step was complete. + Removing a signal can lock people out, so every profile now names its + administrator: an installation that names nobody has none, and says so at + boot. The token's tenant still bounds what a request may *reach* — that is + context, not authority, and it is what a service account has instead of a + membership. +8. **`membership.status`** for suspension (ADR-0011 §2), when membership + management ships. **Shipped.** `active | suspended`; the other two states + ADR-0011 listed are covered elsewhere — `invited` is a row in the invitation + table, and `revoked` is the absence of the membership. A suspended membership + grants nothing while it stands and still records where somebody belongs and + in what role, so a suspended owner is not an owner who can act, and the rule + from (6) refuses a suspension exactly where it would refuse a removal. +9. **Enforcement** — ADR-0011 §7 is still unmet, and the membership-management + UI this ADR describes is exactly what it gates. **Open, and deliberately + not attempted with the rest.** Everything above is enforced today by the + gears themselves: an organization write needs an owner or a platform + administrator, membership decides the tenant clamp, and a suspended + membership reaches nothing. What is still missing is the *policy* half — + `privilege_for` maps no resource type, so the Studio PDP answers every + request with the tenant clamp and the grant evaluation beside it is + unreachable. Turning that on means naming the privileges, the roles that + carry them and the grants that hold them, and getting any of it wrong denies + requests that work today. It is a piece of work with its own risk and its own + ADR, not a coda to this one. + +## What is left after all of this + +Two things, both named above and neither blocking what shipped: + +- **(9)**, the policy half of enforcement. +- **The clamp still admits the platform root from a token.** A service account + has a tenant and no memberships, and creating an organization happens under + the root before any membership exists, so the root cannot simply be dropped + from what a token may reach. It grants nothing administrative any more — (7) + saw to that — but somebody whose identity provider puts them in the root can + still *see* the tenant tree. Closing it means telling a person from a service + apart, which is a question about the platform's subject model rather than + about Studio's. diff --git a/keycloak/realm-studio.json b/keycloak/realm-studio.json index 7139d6f3..61fd06de 100644 --- a/keycloak/realm-studio.json +++ b/keycloak/realm-studio.json @@ -6,6 +6,7 @@ "accessTokenLifespan": 3600, "users": [ { + "id": "00000000-0000-4000-8000-000000000a11", "username": "admin", "enabled": true, "email": "admin@studio.local", diff --git a/studio-backend/Cargo.lock b/studio-backend/Cargo.lock index 1428cf74..e8b7b173 100644 --- a/studio-backend/Cargo.lock +++ b/studio-backend/Cargo.lock @@ -2495,6 +2495,7 @@ dependencies = [ "clap", "futures-util", "gts", + "hex", "http-body-util", "hyper", "hyper-util", @@ -2509,6 +2510,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2 0.10.9", "testcontainers", "testcontainers-modules", "time", diff --git a/studio-backend/Cargo.toml b/studio-backend/Cargo.toml index 5b55e28d..6555b8fb 100644 --- a/studio-backend/Cargo.toml +++ b/studio-backend/Cargo.toml @@ -88,6 +88,11 @@ tracing = "0.1" tokio-util = "0.7" utoipa = "5" uuid = { version = "1", features = ["serde", "v4", "v5"] } +# Invitation tokens are stored as digests, never as themselves: a read of the +# table must not yield working tokens. Both crates are already in the lockfile +# transitively, so declaring them adds nothing to the build. +sha2 = "0.10" +hex = "0.4" toolkit-macros = { package = "cf-gears-toolkit-macros", git = "https://github.com/constructorfabric/gears-rust", branch = "main" } credstore-sdk = { package = "cf-gears-credstore-sdk", git = "https://github.com/constructorfabric/gears-rust", branch = "main" } # Strict ${VAR} pre-expansion of the config file (#65): the toolkit loader diff --git a/studio-backend/config/dev.yaml b/studio-backend/config/dev.yaml index 4f13c534..e5d7edbf 100644 --- a/studio-backend/config/dev.yaml +++ b/studio-backend/config/dev.yaml @@ -541,6 +541,17 @@ gears: # postgres.yaml documents for itself. server: "pg_main" dbname: "studio_users" + config: + # Who administers this installation. Each subject listed here gets a + # membership of the platform root at every start, which is what being a + # platform administrator is (ADR-0018 §3) — and ADR-0011 §4 asks for a + # deliberately provisioned identity rather than whoever logs in first. + # The static-token admin below. Nothing else makes an administrator now: + # the token's tenant stopped being one (ADR-0018 §3), so an installation + # that names nobody here has no administrator at all — the gear says so at + # boot. + platform_admins: + - "00000000-0000-0000-0000-00000000a001" graph-storage: database: diff --git a/studio-backend/config/docker.yaml b/studio-backend/config/docker.yaml index 424b5abc..c692edef 100644 --- a/studio-backend/config/docker.yaml +++ b/studio-backend/config/docker.yaml @@ -426,6 +426,21 @@ gears: # Canonical identity store (users, logins, memberships, aliases). # backend-bootstrap creates this database from config on start. dbname: "studio_users" + config: + # Who administers this installation. Each subject listed here gets a + # membership of the platform root at every start, which is what being a + # platform administrator is (ADR-0018 §3) — and ADR-0011 §4 asks for a + # deliberately provisioned identity rather than whoever logs in first. + # The realm's `admin` user, by the id realm-studio.json pins. Nothing else + # makes an administrator now: the token's tenant stopped being one + # (ADR-0018 §3), so an installation that names nobody here has no + # administrator at all — the gear says so at boot. + # + # A deployment with its own identity provider puts its own subject ids + # here; there is no other way in, and that is the point — an administrator + # is provisioned deliberately, never claimed by whoever signs in. + platform_admins: + - "00000000-0000-4000-8000-000000000a11" studio-credstore-pg: database: diff --git a/studio-backend/config/k8s.yaml b/studio-backend/config/k8s.yaml index c44b95ef..9c90e185 100644 --- a/studio-backend/config/k8s.yaml +++ b/studio-backend/config/k8s.yaml @@ -408,6 +408,16 @@ gears: # Canonical identity store (users, logins, memberships, aliases). # backend-bootstrap creates this database from config on start. dbname: "studio_users" + config: + # Who administers this installation. Each subject listed here gets a + # membership of the platform root at every start, which is what being a + # platform administrator is (ADR-0018 §3) — and ADR-0011 §4 asks for a + # deliberately provisioned identity rather than whoever logs in first. + # Set this to the subject ids of the people who administer this + # installation. Nothing else makes an administrator: the token's tenant + # stopped being one (ADR-0018 §3), and an installation that names nobody + # here has none — the gear says so at boot. + platform_admins: [] studio-credstore-pg: database: diff --git a/studio-backend/config/oidc.yaml b/studio-backend/config/oidc.yaml index f633a70b..c67df128 100644 --- a/studio-backend/config/oidc.yaml +++ b/studio-backend/config/oidc.yaml @@ -401,6 +401,21 @@ gears: # Canonical identity store (users, logins, memberships, aliases). # backend-bootstrap creates this database from config on start. dbname: "studio_users" + config: + # Who administers this installation. Each subject listed here gets a + # membership of the platform root at every start, which is what being a + # platform administrator is (ADR-0018 §3) — and ADR-0011 §4 asks for a + # deliberately provisioned identity rather than whoever logs in first. + # The realm's `admin` user, by the id realm-studio.json pins. Nothing else + # makes an administrator now: the token's tenant stopped being one + # (ADR-0018 §3), so an installation that names nobody here has no + # administrator at all — the gear says so at boot. + # + # A deployment with its own identity provider puts its own subject ids + # here; there is no other way in, and that is the point — an administrator + # is provisioned deliberately, never claimed by whoever signs in. + platform_admins: + - "00000000-0000-4000-8000-000000000a11" studio-credstore-pg: database: diff --git a/studio-backend/config/postgres.yaml b/studio-backend/config/postgres.yaml index f67840aa..15b1e2bf 100644 --- a/studio-backend/config/postgres.yaml +++ b/studio-backend/config/postgres.yaml @@ -340,6 +340,17 @@ gears: # Canonical identity store (users, logins, memberships, aliases). # backend-bootstrap creates this database from config on start. dbname: "studio_users" + config: + # Who administers this installation. Each subject listed here gets a + # membership of the platform root at every start, which is what being a + # platform administrator is (ADR-0018 §3) — and ADR-0011 §4 asks for a + # deliberately provisioned identity rather than whoever logs in first. + # The static-token admin below. Nothing else makes an administrator now: + # the token's tenant stopped being one (ADR-0018 §3), so an installation + # that names nobody here has no administrator at all — the gear says so at + # boot. + platform_admins: + - "00000000-0000-0000-0000-00000000a001" file-storage: database: diff --git a/studio-backend/src/access_config.rs b/studio-backend/src/access_config.rs new file mode 100644 index 00000000..b31dd6f9 --- /dev/null +++ b/studio-backend/src/access_config.rs @@ -0,0 +1,192 @@ +//! An organization's Studio access config: who holds which role in it. +//! +//! The document lives in account-management's tenant metadata under +//! `cf.studio.access.config.v1~`, and it is what the Studio PDP reads to decide +//! whether a caller holds a privilege (`studio_authz_plugin`). Three places in +//! this assembly used to carry their own copy of its shape — the directory that +//! writes the owner grant, the identity gear that asks whether somebody owns an +//! organization, and the PDP that evaluates it — which is three chances for the +//! written shape and the read shape to disagree about a field name nobody +//! notices until a grant silently stops matching. +//! +//! This module is the shape, the read and the write. The PDP keeps its own +//! deserialization for now: it also carries `roles` and privilege expansion, +//! and it sits on the authorization path where a refactor is not free. Folding +//! it in is worth doing once something else needs roles. +//! +//! **Membership is the authority for organization access (ADR-0011 §2); this +//! document is what the PDP happens to evaluate.** They are written together +//! and must not drift — which is the other reason for one writer rather than +//! three. + +use account_management_sdk::{AccountManagementClient, UpsertMetadataRequest}; +use anyhow::{Context, Result}; +use gts::GtsTypeId; +use serde::Deserialize; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// The tenant-metadata type the access config is stored under. +pub const ACCESS_METADATA_TYPE: &str = + "gts.cf.core.am.tenant_metadata.v1~cf.studio.access.config.v1~"; + +/// `subjectType` for a grant naming one person rather than a team. +const SUBJECT_MEMBER: &str = "member"; +/// `scopeType` for a grant covering a whole organization. +const SCOPE_ORG: &str = "org"; +/// The role key that makes somebody an owner. +pub const ROLE_OWNER: &str = "owner"; + +/// The subset of the document this assembly writes and asks questions of. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AccessConfig { + #[serde(default)] + grants: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct GrantDef { + #[serde(rename = "subjectType")] + subject_type: String, + #[serde(rename = "subjectId")] + subject_id: String, + #[serde(rename = "roleKey")] + role_key: String, + #[serde(rename = "scopeType")] + scope_type: String, +} + +impl AccessConfig { + /// Does `subject` hold the organization-wide owner grant? + /// + /// `subject` is a token subject, because that is what the grants record — + /// see the note on [`set_owner_grant`]. + #[must_use] + pub fn grants_ownership_to(&self, subject: &str) -> bool { + self.grants.iter().any(|g| { + g.subject_type == SUBJECT_MEMBER + && g.subject_id == subject + && g.role_key == ROLE_OWNER + && g.scope_type == SCOPE_ORG + }) + } +} + +/// Read an organization's effective access config. +/// +/// Resolves through the ancestor chain, the same way the PDP sees it. A tenant +/// with no document of its own, or an unreadable one, reads as "no grants" — +/// which denies rather than permits. +pub async fn read( + am: &dyn AccountManagementClient, + ctx: &SecurityContext, + tenant_id: Uuid, +) -> AccessConfig { + match am + .resolve_metadata(ctx, tenant_id, GtsTypeId::new(ACCESS_METADATA_TYPE)) + .await + { + Ok(Some(entry)) => serde_json::from_value(entry.value).unwrap_or_default(), + _ => AccessConfig::default(), + } +} + +/// Give `subject` the organization-wide owner grant, or take it away. +/// +/// Idempotent: the matching grant is removed and re-added, so calling twice +/// leaves one grant and calling with `owner = false` leaves none. +/// +/// `subject` is a **token subject**, not a canonical person id. That is what +/// the PDP matches today (`grant.subjectId == request.subject.id`), so writing +/// anything else here would produce a grant that never matches. Moving the +/// grant model onto the person is ADR-0006 follow-up 2, and it has to move on +/// both sides at once. +pub async fn set_owner_grant( + am: &dyn AccountManagementClient, + ctx: &SecurityContext, + tenant_id: Uuid, + tenant_name: &str, + subject: &str, + owner: bool, +) -> Result<()> { + let type_id = GtsTypeId::new(ACCESS_METADATA_TYPE); + let mut config = match am.get_metadata(ctx, tenant_id, type_id.clone()).await { + Ok(entry) => entry.value, + // No document yet: a fresh organization has none until its first grant. + Err(_) => serde_json::json!({ "model": "tenant", "roles": [], "grants": [] }), + }; + let object = config + .as_object_mut() + .context("organization access config is not an object")?; + let grants = object + .entry("grants") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("organization access grants are not an array")?; + + grants.retain(|grant| { + let field = |name: &str| grant.get(name).and_then(serde_json::Value::as_str); + field("subjectType") != Some(SUBJECT_MEMBER) + || field("subjectId") != Some(subject) + || field("scopeType") != Some(SCOPE_ORG) + || field("roleKey") != Some(ROLE_OWNER) + }); + if owner { + grants.push(serde_json::json!({ + "id": Uuid::new_v4().to_string(), + "subjectType": SUBJECT_MEMBER, + "subjectId": subject, + "subjectName": subject, + "roleKey": ROLE_OWNER, + "scopeType": SCOPE_ORG, + "scopeId": tenant_id.to_string(), + "scopeName": tenant_name, + })); + } + + am.upsert_metadata(ctx, tenant_id, UpsertMetadataRequest::new(type_id, config)) + .await + .map(|_| ()) + .map_err(|error| anyhow::anyhow!("cannot update the organization's owner grant: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(json: serde_json::Value) -> AccessConfig { + serde_json::from_value(json).expect("valid access config") + } + + #[test] + fn an_org_scoped_owner_grant_is_ownership() { + let cfg = config(serde_json::json!({ + "grants": [{ + "subjectType": "member", "subjectId": "ada", + "roleKey": "owner", "scopeType": "org" + }] + })); + assert!(cfg.grants_ownership_to("ada")); + assert!(!cfg.grants_ownership_to("bob")); + } + + #[test] + fn a_grant_that_differs_in_any_field_is_not_ownership() { + // Each of these is one field away from the real thing, and each of them + // is a way the write side and the read side could quietly disagree. + for grant in [ + serde_json::json!({"subjectType": "team", "subjectId": "ada", "roleKey": "owner", "scopeType": "org"}), + serde_json::json!({"subjectType": "member", "subjectId": "ada", "roleKey": "admin", "scopeType": "org"}), + serde_json::json!({"subjectType": "member", "subjectId": "ada", "roleKey": "owner", "scopeType": "project"}), + ] { + let cfg = config(serde_json::json!({ "grants": [grant] })); + assert!(!cfg.grants_ownership_to("ada")); + } + } + + #[test] + fn a_document_with_no_grants_denies() { + assert!(!AccessConfig::default().grants_ownership_to("ada")); + assert!(!config(serde_json::json!({})).grants_ownership_to("ada")); + } +} diff --git a/studio-backend/src/connectors/service.rs b/studio-backend/src/connectors/service.rs index ac4f9b85..795750d2 100644 --- a/studio-backend/src/connectors/service.rs +++ b/studio-backend/src/connectors/service.rs @@ -529,6 +529,54 @@ impl ConnectorService { /// the connection's own tenant, so deleting an inherited connection from a /// workspace touches the organization's catalogue — and fails with the /// authorization error it should if the caller may not write there. + /// Delete the personal connections `person` created in this tenant. + /// + /// Called when somebody leaves an organization. A *personal* connection + /// holds that person's own credential in credstore, and under ADR-0012 the + /// record is also their proof of controlling the external account — so an + /// organization they are no longer part of must not keep either. Shared + /// connections belong to the organization and stay. + /// + /// `created_by` stores the subject that wrote the row, so it is resolved to + /// a person before being compared: somebody who created a connection under + /// one of their logins is still its creator under another (ADR-0014). + /// + /// Returns how many were removed. A failure on one is logged and the rest + /// are still taken: leaving half a person's credentials behind is worse + /// than leaving none. + pub async fn delete_personal_of( + &self, + ctx: &SecurityContext, + tenant: Uuid, + people: &dyn PersonResolver, + person: &str, + ) -> anyhow::Result { + let mut removed = 0; + for connection in self.list(ctx, tenant).await? { + if connection.scope != ConnectionScope::Personal.as_str() + || connection.created_by.trim().is_empty() + { + continue; + } + let creator = people + .resolve_recorded_subject(&connection.created_by) + .await + .unwrap_or(None); + if creator.as_deref() != Some(person) { + continue; + } + match self.delete(ctx, tenant, connection.id).await { + Ok(true) => removed += 1, + Ok(false) => {} + Err(error) => tracing::warn!( + connection = %connection.id, + "studio-connector: could not remove a leaver's personal connection: {error:#}" + ), + } + } + Ok(removed) + } + pub async fn delete( &self, ctx: &SecurityContext, diff --git a/studio-backend/src/identity_directory/mod.rs b/studio-backend/src/identity_directory/mod.rs index 8d799ef5..c30ade21 100644 --- a/studio-backend/src/identity_directory/mod.rs +++ b/studio-backend/src/identity_directory/mod.rs @@ -26,7 +26,7 @@ use service::IdentityDirectoryService; /// ClientHub key under which the federated-identity reader is published. pub const IDP_DIRECTORY_INSTANCE_ID: &str = "cf.studio._.idp_directory.v1~"; -/// Read the external accounts the IdP has brokered onto one of its users. +/// What the IdP knows about one of its own subjects. /// /// A second proof-of-control channel for identity attribution (ADR-0012 /// follow-up 2): a person who signed in through GitHub has already completed @@ -38,17 +38,28 @@ pub const IDP_DIRECTORY_INSTANCE_ID: &str = "cf.studio._.idp_directory.v1~"; /// about the person signed in right now; a bulk or arbitrary-subject read would /// make it an account-enumeration surface, and nothing needs one. #[async_trait] -pub trait FederatedIdentityReader: Send + Sync + 'static { +pub trait IdpDirectoryReader: Send + Sync + 'static { /// The external accounts brokered onto `subject`, or an empty list when the /// realm user has no brokered login. async fn federated_accounts(&self, subject: &str) -> anyhow::Result>; + + /// The address the realm has verified for `subject`, lowercased, or `None` + /// when there is none to trust. + /// + /// The only address in this system that may be decided from: the profile + /// e-mail is self-service and therefore a claim, not a fact. + async fn verified_email(&self, subject: &str) -> anyhow::Result>; } #[async_trait] -impl FederatedIdentityReader for IdentityDirectoryService { +impl IdpDirectoryReader for IdentityDirectoryService { async fn federated_accounts(&self, subject: &str) -> anyhow::Result> { IdentityDirectoryService::federated_accounts(self, subject).await } + + async fn verified_email(&self, subject: &str) -> anyhow::Result> { + IdentityDirectoryService::verified_email(self, subject).await + } } #[toolkit::gear( @@ -90,12 +101,11 @@ impl Gear for IdentityDirectoryGear { // leaves the identity gear's IdP proof channel unavailable and its // connector channel untouched. if let Some(svc) = service.clone() { - let reader: Arc = svc; - ctx.client_hub() - .register_scoped::( - ClientScope::gts_id(IDP_DIRECTORY_INSTANCE_ID), - reader, - ); + let reader: Arc = svc; + ctx.client_hub().register_scoped::( + ClientScope::gts_id(IDP_DIRECTORY_INSTANCE_ID), + reader, + ); } self.service @@ -137,6 +147,20 @@ impl toolkit::contracts::RestApiCapability for IdentityDirectoryGear { }) .ok(), ); - Ok(rest::register_routes(router, openapi, service, memberships)) + // Same phase and the same reasoning as the recorder above. + let people = rest::People( + ctx.client_hub() + .get_scoped::(&ClientScope::gts_id( + crate::user_profile::IDENTITY_INSTANCE_ID, + )) + .ok(), + ); + Ok(rest::register_routes( + router, + openapi, + service, + memberships, + people, + )) } } diff --git a/studio-backend/src/identity_directory/rest.rs b/studio-backend/src/identity_directory/rest.rs index c2592f9a..edfd1d82 100644 --- a/studio-backend/src/identity_directory/rest.rs +++ b/studio-backend/src/identity_directory/rest.rs @@ -8,7 +8,7 @@ use toolkit_canonical_errors::resource_error; use toolkit_security::SecurityContext; use uuid::Uuid; -use super::service::{DirectoryIdentity, IdentityDirectoryService, PLATFORM_ROOT_TENANT_ID}; +use super::service::{DirectoryIdentity, IdentityDirectoryService}; #[resource_error(gts_id!("cf.studio.identity.directory.v1~"))] pub struct IdentityDirectoryError; @@ -68,6 +68,13 @@ pub struct PlatformIdentityListDto { #[derive(Clone)] pub struct Memberships(pub Option>); +/// Who a subject is, for the administrator gate. +/// +/// `None` when studio-user is inert, and the gate then has only the token to go +/// on — which is what it has always had. +#[derive(Clone)] +pub struct People(pub Option>); + #[derive(Debug)] #[toolkit_macros::api_dto(response)] pub struct BackfillReportDto { @@ -93,13 +100,31 @@ fn to_dto(identity: DirectoryIdentity) -> PlatformIdentityDto { } } -fn require_platform_admin(ctx: &SecurityContext) -> ApiResult<()> { - if ctx.subject_tenant_id() != PLATFORM_ROOT_TENANT_ID { - return Err(IdentityDirectoryError::permission_denied() +/// Is the caller a platform administrator? +/// +/// A membership of the platform root, and nothing else (ADR-0018 §3). The +/// token's tenant was accepted here too while that migration ran; it is an +/// answer about one login rather than about the person, and this is the step +/// that removes it. +/// +/// Without studio-user there is nobody to ask, and every route behind this gate +/// is administrative — so it refuses rather than falling back to the signal +/// just retired. +async fn require_platform_admin(ctx: &SecurityContext, people: &People) -> ApiResult<()> { + let by_membership = match people.0.as_deref() { + Some(reader) => reader + .is_platform_admin(&ctx.subject_id().to_string()) + .await + .unwrap_or(false), + None => false, + }; + if by_membership { + Ok(()) + } else { + Err(IdentityDirectoryError::permission_denied() .with_reason("PLATFORM_ADMIN_REQUIRED") - .create()); + .create()) } - Ok(()) } fn configured_service( @@ -116,9 +141,10 @@ fn configured_service( async fn list_identities( Extension(ctx): Extension, + Extension(people): Extension, Extension(service): Extension>>, ) -> ApiResult> { - require_platform_admin(&ctx)?; + require_platform_admin(&ctx, &people).await?; let service = configured_service(service)?; let directory = service.list(&ctx).await.map_err(|error| { CanonicalError::internal(format!("identity directory failed: {error:#}")).create() @@ -131,12 +157,13 @@ async fn list_identities( async fn assign_identity( Extension(ctx): Extension, + Extension(people): Extension, Extension(service): Extension>>, Extension(memberships): Extension, Path(identity_id): Path, Json(req): Json, ) -> ApiResult { - require_platform_admin(&ctx)?; + require_platform_admin(&ctx, &people).await?; let role = req.role.trim().to_ascii_lowercase(); if !matches!(role.as_str(), "owner" | "member") { return Err(IdentityDirectoryError::invalid_argument() @@ -160,10 +187,11 @@ async fn assign_identity( async fn backfill_memberships( Extension(ctx): Extension, + Extension(people): Extension, Extension(service): Extension>>, Extension(memberships): Extension, ) -> ApiResult> { - require_platform_admin(&ctx)?; + require_platform_admin(&ctx, &people).await?; let service = configured_service(service)?; let Some(recorder) = memberships.0.as_deref() else { return Err(CanonicalError::service_unavailable() @@ -191,6 +219,7 @@ pub fn register_routes( openapi: &dyn OpenApiRegistry, service: Option>, memberships: Memberships, + people: People, ) -> Router { let router = OperationBuilder::get("/studio-identity/v1/users") .operation_id("studio_identity.list_users") @@ -258,4 +287,5 @@ pub fn register_routes( // reaches both of the earlier routes. .layer(Extension(service)) .layer(Extension(memberships)) + .layer(Extension(people)) } diff --git a/studio-backend/src/identity_directory/service.rs b/studio-backend/src/identity_directory/service.rs index ab71df35..40e91d66 100644 --- a/studio-backend/src/identity_directory/service.rs +++ b/studio-backend/src/identity_directory/service.rs @@ -2,10 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use account_management_sdk::{AccountManagementClient, UpsertMetadataRequest}; +use account_management_sdk::AccountManagementClient; use anyhow::{Context, Result, bail}; use futures_util::stream::{self, StreamExt}; -use gts::GtsTypeId; use reqwest::Client; use serde::Deserialize; use toolkit_security::SecurityContext; @@ -25,7 +24,6 @@ const PAGE_SIZE: usize = 200; /// that this deployment needs the directory to paginate to its caller rather /// than read the realm on every load. const MAX_PAGES: usize = 10; -const ACCESS_METADATA_TYPE: &str = "gts.cf.core.am.tenant_metadata.v1~cf.studio.access.config.v1~"; /// What one read of the realm saw. #[derive(Debug, Clone, PartialEq, Eq)] @@ -107,6 +105,10 @@ struct KeycloakUser { #[serde(default)] username: String, email: Option, + /// Whether the realm has verified that address. An unverified address is + /// a claim by whoever typed it, and nothing may be decided from it. + #[serde(default)] + email_verified: bool, first_name: Option, last_name: Option, created_timestamp: Option, @@ -369,6 +371,41 @@ impl IdentityDirectoryService { .collect()) } + /// The address the realm has verified for `subject`, if any. + /// + /// `None` when the user has no address, when the realm has not verified it, + /// or when the user cannot be read. All three mean the same thing to a + /// caller: there is nothing here that may be decided from. + /// + /// This exists because the profile e-mail cannot serve: it is self-service + /// (`POST /studio-user/v1/me`), so deciding anything from it would let + /// somebody claim an address by typing it. An invitation matched on a + /// self-declared address is an invitation anybody can take. + pub async fn verified_email(&self, subject: &str) -> Result> { + let token = self.admin_token().await?; + let url = format!( + "{}/admin/realms/{}/users/{}", + self.admin_base_url, self.realm, subject + ); + let user = self + .http + .get(url) + .bearer_auth(token) + .send() + .await + .context("read the realm user for its verified address")? + .error_for_status() + .context("Keycloak rejected the user read")? + .json::() + .await + .context("decode the realm user")?; + Ok(user + .email + .filter(|_| user.email_verified) + .map(|e| e.trim().to_lowercase()) + .filter(|e| !e.is_empty())) + } + /// The external accounts brokered onto `subject`, for a caller that already /// knows the subject is its own. /// @@ -622,12 +659,13 @@ impl IdentityDirectoryService { // Owner is also a real organization-wide access grant understood by // Studio's PDP. Member is tenant membership without that elevated // grant; project roles can still be assigned independently. - self.set_owner_grant( + crate::access_config::set_owner_grant( + self.account_management.as_ref(), ctx, tenant_id, &tenant.name, &identity_id_string, - organization_role == "owner", + organization_role == crate::access_config::ROLE_OWNER, ) .await?; @@ -714,56 +752,6 @@ impl IdentityDirectoryService { } Ok((recorded, failed)) } - - async fn set_owner_grant( - &self, - ctx: &SecurityContext, - tenant_id: Uuid, - tenant_name: &str, - identity_id: &str, - owner: bool, - ) -> Result<()> { - let type_id = GtsTypeId::new(ACCESS_METADATA_TYPE); - let mut config = match self - .account_management - .get_metadata(ctx, tenant_id, type_id.clone()) - .await - { - Ok(entry) => entry.value, - Err(_) => serde_json::json!({ "model": "tenant", "roles": [], "grants": [] }), - }; - let config_object = config - .as_object_mut() - .context("organization access config is not an object")?; - let grants = config_object - .entry("grants") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut() - .context("organization access grants are not an array")?; - grants.retain(|grant| { - grant.get("subjectType").and_then(|value| value.as_str()) != Some("member") - || grant.get("subjectId").and_then(|value| value.as_str()) != Some(identity_id) - || grant.get("scopeType").and_then(|value| value.as_str()) != Some("org") - || grant.get("roleKey").and_then(|value| value.as_str()) != Some("owner") - }); - if owner { - grants.push(serde_json::json!({ - "id": Uuid::new_v4().to_string(), - "subjectType": "member", - "subjectId": identity_id, - "subjectName": identity_id, - "roleKey": "owner", - "scopeType": "org", - "scopeId": tenant_id.to_string(), - "scopeName": tenant_name, - })); - } - self.account_management - .upsert_metadata(ctx, tenant_id, UpsertMetadataRequest::new(type_id, config)) - .await - .map_err(|error| anyhow::anyhow!("cannot update organization owner grant: {error}"))?; - Ok(()) - } } #[cfg(test)] diff --git a/studio-backend/src/main.rs b/studio-backend/src/main.rs index a1026d42..1a18ad41 100644 --- a/studio-backend/src/main.rs +++ b/studio-backend/src/main.rs @@ -4,6 +4,7 @@ //! lives in the linked gear crates (see `registered_gears.rs`); this binary //! only loads layered config and hands control to `toolkit::bootstrap`. +mod access_config; // the Studio access-config document: one shape, one reader, one writer mod artifact_ingest; // pull issues/PRs from a connector source into the graph as GTS nodes mod components_catalog; // connector to crates.io: catalogue our published gears + versions in the graph mod connectors; // source connectors: driver plugins + tenant connection catalogue @@ -22,6 +23,7 @@ mod kit_registry; // Git-backed kit catalogue + project-scoped desired installat #[cfg(feature = "llm")] mod llm_proxy; // OpenAI-compatible LLM proxy for Theia AI in IDE sessions (llm feature) mod notify; // studio-notify: durable delivery queue for notifications (toolkit-db outbox) +mod organizations; // studio-organizations: a person creates an organization and owns it (ADR-0018) mod pagination; // one ?offset=&limit= contract + total for every list endpoint mod registered_gears; mod scheduler; // studio-scheduler: cron/interval schedules that enqueue into studio-tasks diff --git a/studio-backend/src/organizations/mod.rs b/studio-backend/src/organizations/mod.rs new file mode 100644 index 00000000..57820186 --- /dev/null +++ b/studio-backend/src/organizations/mod.rs @@ -0,0 +1,163 @@ +//! studio-organizations — a person creates an organization and owns it. +//! +//! The first of ADR-0018's follow-ups. Before it, organizations were created by +//! the browser calling account-management's `createTenant` directly, which +//! cannot do this job: an organization needs a tenant *and* an owner, and a +//! client that writes only the first produces one nobody owns and nobody sees. +//! +//! The gear owns no storage. It composes: account-management holds the tenant, +//! `studio-user` holds the membership that makes somebody its owner, and the +//! tenant's access config holds the grant the Studio PDP reads. What it adds is +//! that those three are written by one operation, in an order that can be +//! resumed (see `service`). + +mod rest; +mod service; + +use std::sync::{Arc, OnceLock}; + +use account_management_sdk::AccountManagementClient; +use async_trait::async_trait; +use axum::Router; +use toolkit::api::OpenApiRegistry; +use toolkit::client_hub::ClientScope; +use toolkit::contracts::RestApiCapability; +use toolkit::{Gear, GearCtx}; +use tracing::{info, warn}; +use uuid::Uuid; + +use serde::Deserialize; + +use service::OrganizationService; + +/// What this installation lets people do with organizations. +#[derive(Debug, Clone, Deserialize)] +pub struct StudioOrganizationsConfig { + /// May a person create one? + /// + /// `true` in the cloud: somebody arrives with no organization and makes + /// their own. `false` in an installation inside one company, where the + /// organization already exists and people are joined to it on first sight + /// (`studio-user.config.on_first_login`). + /// + /// These two settings are the whole difference between the two ways Studio + /// ships (ADR-0018 §4). The domain model does not change, and neither does + /// any authorization path — only how a person comes by their first + /// organization. + #[serde(default = "yes")] + pub self_service: bool, +} + +const fn yes() -> bool { + true +} + +impl Default for StudioOrganizationsConfig { + fn default() -> Self { + Self { self_service: true } + } +} + +/// The tenant new organizations are created under. +/// +/// The platform root, the same constant the rest of the assembly uses for it. +const PLATFORM_ROOT_TENANT_ID: Uuid = Uuid::from_u128(1); + +#[toolkit::gear( + name = "studio-organizations", + deps = [account_management], + capabilities = [rest] +)] +#[derive(Default)] +pub struct StudioOrganizationsGear { + service: OnceLock>>, + self_service: OnceLock, +} + +#[async_trait] +impl Gear for StudioOrganizationsGear { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let cfg = ctx + .config_or_default::() + .unwrap_or_default(); + if !cfg.self_service { + info!( + "studio-organizations: self-service creation is off — this installation's people \ + are joined to an organization that already exists" + ); + } + let _ = self.self_service.set(cfg.self_service); + Ok(()) + } +} + +#[async_trait] +impl RestApiCapability for StudioOrganizationsGear { + fn register_rest( + &self, + ctx: &GearCtx, + router: Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let service = build_service(ctx); + let _ = self.service.set(service.clone()); + let self_service = self.self_service.get().copied().unwrap_or(true); + Ok(rest::register_routes( + router, + openapi, + service, + rest::SelfService(self_service), + )) + } +} + +/// Both halves, or nothing. +/// +/// Without `studio-user` there is nowhere to record who owns the new +/// organization, and creating a tenant anyway would produce exactly the +/// ownerless organization this gear exists to prevent — so the route answers +/// 503 instead. +fn build_service(ctx: &GearCtx) -> Option> { + let am = ctx + .client_hub() + .get::() + .inspect_err(|_| warn!("studio-organizations: account-management is not available")) + .ok()?; + let memberships = ctx + .client_hub() + .get_scoped::(&ClientScope::gts_id( + crate::user_profile::IDENTITY_INSTANCE_ID, + )) + .inspect_err(|_| { + warn!( + "studio-organizations: studio-user is not available — organization creation \ + answers 503 rather than creating one nobody owns" + ); + }) + .ok()?; + let evictions = ctx + .client_hub() + .get_scoped::(&ClientScope::gts_id( + crate::user_profile::IDENTITY_INSTANCE_ID, + )) + .inspect_err(|_| { + warn!( + "studio-organizations: studio-user is not available — organization deletion \ + answers 503 rather than leaving memberships behind a deleted tenant" + ); + }) + .ok()?; + let people = ctx + .client_hub() + .get_scoped::(&ClientScope::gts_id( + crate::user_profile::IDENTITY_INSTANCE_ID, + )) + .ok()?; + Some(Arc::new(OrganizationService::new( + am, + memberships, + evictions, + people, + PLATFORM_ROOT_TENANT_ID, + ))) +} diff --git a/studio-backend/src/organizations/rest.rs b/studio-backend/src/organizations/rest.rs new file mode 100644 index 00000000..f8661340 --- /dev/null +++ b/studio-backend/src/organizations/rest.rs @@ -0,0 +1,242 @@ +//! HTTP surface for creating an organization. + +use std::sync::Arc; + +use axum::{Extension, Router, extract::Path}; +use toolkit::api::canonical_prelude::*; +use toolkit::api::operation_builder::{CORE_GLOBAL_BASE_LICENSE_FEATURE, LicenseFeature}; +use toolkit::api::{OpenApiRegistry, OperationBuilder}; +use toolkit_canonical_errors::resource_error; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::service::{OrganizationService, Step}; + +#[resource_error(gts_id!("cf.studio._.organizations.v1~"))] +pub struct OrganizationError; + +struct License; +impl AsRef for License { + fn as_ref(&self) -> &'static str { + CORE_GLOBAL_BASE_LICENSE_FEATURE + } +} +impl LicenseFeature for License {} + +#[derive(Debug)] +#[toolkit_macros::api_dto(request)] +pub struct CreateOrganizationRequest { + /// Free text. Not unique — two organizations may share a name. + pub name: String, + /// The id a previous attempt reported, to finish what it started. Omit to + /// create a new organization. + #[serde(default)] + pub organization_id: Option, +} + +/// Whether this installation lets people create organizations. +#[derive(Clone, Copy)] +pub struct SelfService(pub bool); + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct OrganizationCapabilitiesDto { + /// When false, a person with no organization is waiting for an invitation + /// or for the installation to join them — and the portal should not offer + /// a control that will be refused. + pub self_service: bool, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct OrganizationDto { + pub id: String, + pub name: String, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct OrganizationDeletionDto { + /// Memberships ended. The caller's own is one of them. + pub people_removed: u32, + /// Personal connections removed with them, and their tokens in credstore. + pub connections_removed: u32, +} + +fn configured(service: Option>) -> ApiResult> { + service.ok_or_else(|| { + CanonicalError::service_unavailable() + .with_detail( + "studio-organizations is not configured: it needs account-management and \ + studio-user, so that a new organization gets both a tenant and an owner", + ) + .create() + }) +} + +async fn organization_capabilities( + Extension(self_service): Extension, +) -> ApiResult> { + Ok(Json(OrganizationCapabilitiesDto { + self_service: self_service.0, + })) +} + +async fn create_organization( + Extension(ctx): Extension, + Extension(service): Extension>>, + Extension(self_service): Extension, + Json(req): Json, +) -> ApiResult> { + if !self_service.0 { + return Err(OrganizationError::permission_denied() + .with_reason("SELF_SERVICE_DISABLED") + .create()); + } + let service = configured(service)?; + let resume = match req.organization_id.as_deref() { + None => None, + Some(raw) => Some(Uuid::parse_str(raw).map_err(|_| { + OrganizationError::invalid_argument() + .with_constraint("organization_id must be a uuid") + .create() + })?), + }; + + match service.create(&ctx, &req.name, resume).await { + Ok(org) => Ok(Json(OrganizationDto { + id: org.id.to_string(), + name: org.name, + })), + // A rejected name is the caller's problem and nothing was created. + Err((Step::Tenant, error, None)) => Err(OrganizationError::invalid_argument() + .with_constraint(format!("{error:#}")) + .create()), + // Past the first write: the organization exists but is not finished. + // The response names it, because that id is the only way to finish it + // and the caller is the only one holding it. + Err((step, error, Some(id))) => Err(CanonicalError::internal(format!( + "the organization was created as {id} but {} could not be written: {error:#}. \ + Repeat this request with organization_id={id} to finish it.", + match step { + Step::Tenant => "its record", + Step::Membership => "your membership of it", + Step::Grant => "your owner grant on it", + } + )) + .create()), + Err((_, error, None)) => Err(CanonicalError::internal(format!("{error:#}")).create()), + } +} + +async fn delete_organization( + Extension(ctx): Extension, + Extension(service): Extension>>, + Path(org_id): Path, +) -> ApiResult> { + let service = configured(service)?; + let org = Uuid::parse_str(&org_id).map_err(|_| { + OrganizationError::invalid_argument() + .with_constraint("organization id must be a uuid") + .create() + })?; + if !service.may_delete(&ctx, org).await { + return Err(OrganizationError::permission_denied() + .with_reason("ORG_OWNER_REQUIRED") + .create()); + } + match service.delete(&ctx, org).await { + Ok(gone) => Ok(Json(OrganizationDeletionDto { + people_removed: gone.people as u32, + connections_removed: gone.connections as u32, + })), + // Account-management refuses a tenant that still has children, and that + // refusal is the caller's to act on: an organization with a workspace or + // a project in it is not disposed of by answering one prompt. + Err(error) => Err(OrganizationError::invalid_argument() + .with_constraint(format!("{error:#}")) + .create()), + } +} + +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + service: Option>, + self_service: SelfService, +) -> Router { + let router = OperationBuilder::get("/studio-organizations/v1/capabilities") + .operation_id("studio_organizations.capabilities") + .summary("What this installation lets people do with organizations") + .description( + "One field today: whether a person may create an organization. The portal reads it \ + so the no-organization screen offers creation where creation is possible and says \ + `wait for an invitation` where it is not — rather than offering a control that \ + answers 403.", + ) + .tag("StudioOrganizations") + .authenticated() + .require_license_features::([]) + .handler(organization_capabilities) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Capabilities", + ) + .error_401(openapi) + .register(router, openapi); + + let router = OperationBuilder::delete("/studio-organizations/v1/organizations/{org_id}") + .operation_id("studio_organizations.delete_organization") + .summary("Delete an organization") + .description( + "The other end of creating one, and what the last person in an organization is \ + pointed at when they try to leave: leaving an organization nobody else is in would \ + abandon it rather than hand it over, so it is deletion that is being asked for, \ + and deletion is a separate, deliberate act (ADR-0018 §6). Its owner may, and so \ + may a platform administrator. Every membership ends and every member's personal \ + connections go with it — the tenant is removed last, because the catalogue holding \ + those connections lives inside it. Refused while the organization still has a \ + workspace or a project: work is not disposed of by answering a prompt.", + ) + .tag("StudioOrganizations") + .authenticated() + .require_license_features::([]) + .path_param("org_id", "Organization (tenant) id") + .handler(delete_organization) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Deleted, and what went with it", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + OperationBuilder::post("/studio-organizations/v1/organizations") + .operation_id("studio_organizations.create_organization") + .summary("Create an organization and own it") + .description( + "Anyone who can sign in may create an organization and becomes its owner \ + (ADR-0018 §2). Creating one writes three things — the tenant, the caller's owner \ + membership, and the owner grant the authorization policy reads — and there is no \ + transaction across the two systems that hold them. If a later write fails the \ + response names the organization it created; repeating the request with that \ + `organization_id` finishes it, and each write is idempotent so repeating is safe.", + ) + .tag("StudioOrganizations") + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "The organization to create") + .handler(create_organization) + .json_response_with_schema::(openapi, StatusCode::OK, "The organization") + .error_400(openapi) + .error_401(openapi) + .error_500(openapi) + .register(router, openapi) + .layer(Extension(service)) + .layer(Extension(self_service)) +} diff --git a/studio-backend/src/organizations/service.rs b/studio-backend/src/organizations/service.rs new file mode 100644 index 00000000..568e40ec --- /dev/null +++ b/studio-backend/src/organizations/service.rs @@ -0,0 +1,272 @@ +//! Creating an organization: one operation, three writes, resumable. +//! +//! A person who can sign in may create an organization and owns it (ADR-0018 +//! §2). "Owns it" is not one fact in one place — it is three, in two systems: +//! +//! 1. the **tenant** in account-management, which is what an organization *is*; +//! 2. the **membership** `(person, org, owner)`, which is the authority for +//! organization access (ADR-0011 §2) and what the portal reads; +//! 3. the **owner grant** in the tenant's access config, which is what the +//! Studio PDP evaluates. +//! +//! There is no transaction across Postgres and account-management, so the +//! operation is ordered and resumable instead: the writes go in the order above, +//! and a failure names the organization it got as far as creating so the same +//! call can be repeated with `organization_id` to finish the rest. Each write is +//! idempotent on its own, so resuming is safe however far the first attempt got. +//! +//! The order matters. The tenant first, because the other two need its id. The +//! membership before the grant, because membership is the authority: an +//! organization the creator can see but cannot yet administer is a worse state +//! than one they cannot see at all, and the first is what the other order would +//! produce. + +use std::sync::Arc; + +use account_management_sdk::{AccountManagementClient, CreateTenantRequest}; +use anyhow::{Result, anyhow}; +use gts::GtsTypeId; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::access_config; +use crate::user_profile::{AssignmentRecorder, MembershipEvictor, OrganizationReader}; + +/// The tenant type an organization has. +/// +/// The same id the portal filters the context switcher on; a tenant of any +/// other type is a workspace or a project and never appears as an organization. +pub const ORGANIZATION_TENANT_TYPE: &str = + "gts.cf.core.am.tenant_type.v1~cf.studio.tenant.organization.v1~"; + +/// Longest organization name accepted. +/// +/// Names are free text and are not unique (ADR-0018 §5); this only stops a name +/// the UI cannot render and the column should not hold. +pub const MAX_NAME_LEN: usize = 120; + +/// What a caller gets back. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Organization { + pub id: Uuid, + pub name: String, +} + +/// How far a failed attempt got, so the error can say what to repeat. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + Tenant, + Membership, + Grant, +} + +/// Trim a submitted name and refuse the ones that are not names. +/// +/// Pure, so the rule is stated as a test. Uniqueness is deliberately *not* +/// checked: two organizations may share a name, and telling somebody they may +/// not call theirs what they want would be us pretending to know better +/// (ADR-0018 §5). +pub fn clean_name(raw: &str) -> Result { + let name = raw.trim(); + if name.is_empty() { + return Err(anyhow!("an organization needs a name")); + } + if name.chars().count() > MAX_NAME_LEN { + return Err(anyhow!( + "an organization name must be at most {MAX_NAME_LEN} characters" + )); + } + Ok(name.to_owned()) +} + +/// What deleting an organization took with it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Deletion { + pub people: usize, + pub connections: usize, +} + +pub struct OrganizationService { + am: Arc, + memberships: Arc, + evictions: Arc, + people: Arc, + /// The tenant every organization is created under. + platform_root: Uuid, +} + +impl OrganizationService { + pub(crate) fn new( + am: Arc, + memberships: Arc, + evictions: Arc, + people: Arc, + platform_root: Uuid, + ) -> Self { + Self { + am, + memberships, + evictions, + people, + platform_root, + } + } + + /// May the caller delete this organization? + /// + /// Its owner may, and so may a platform administrator — the second for the + /// case ADR-0018 §6 calls break-glass, where the owner is gone and somebody + /// still has to dispose of what they left. Nobody else, whatever they can + /// otherwise reach inside it. + /// + /// Ownership is read from the access config rather than from the membership + /// row because that is the document the authorization policy evaluates, and + /// a deletion gate that disagreed with the policy would be a gate in name. + pub async fn may_delete(&self, ctx: &SecurityContext, org_id: Uuid) -> bool { + let subject = ctx.subject_id().to_string(); + access_config::read(self.am.as_ref(), ctx, org_id) + .await + .grants_ownership_to(&subject) + || self + .people + .is_platform_admin(&subject) + .await + .unwrap_or(false) + } + + /// Create an organization owned by the caller, or finish creating one. + /// + /// `resume` carries the id from a previous attempt's error. With it, the + /// tenant is looked up rather than created and the remaining writes are + /// repeated; without it, a fresh organization is created. + pub async fn create( + &self, + ctx: &SecurityContext, + name: &str, + resume: Option, + ) -> Result)> { + let name = clean_name(name).map_err(|e| (Step::Tenant, e, None))?; + let subject = ctx.subject_id().to_string(); + + let org = match resume { + Some(id) => self + .am + .get_tenant(ctx, id) + .await + .map(|t| Organization { id, name: t.name }) + .map_err(|e| { + ( + Step::Tenant, + anyhow!("cannot resume organization {id}: {e}"), + Some(id), + ) + })?, + None => { + let id = Uuid::new_v4(); + let request = CreateTenantRequest::new( + id, + self.platform_root, + name.clone(), + GtsTypeId::new(ORGANIZATION_TENANT_TYPE), + ); + self.am.create_tenant(ctx, request).await.map_err(|e| { + ( + Step::Tenant, + anyhow!("cannot create organization: {e}"), + None, + ) + })?; + Organization { id, name } + } + }; + + // The authority for access, before the document the PDP happens to read. + self.memberships + .record_creation(&subject, org.id) + .await + .map_err(|e| (Step::Membership, e, Some(org.id)))?; + + access_config::set_owner_grant(self.am.as_ref(), ctx, org.id, &org.name, &subject, true) + .await + .map_err(|e| (Step::Grant, e, Some(org.id)))?; + + Ok(org) + } + + /// Delete an organization: the memberships first, then the tenant. + /// + /// The order is creation's, reversed, and for the same reason creation has + /// one. The memberships and the personal connections that hang off them live + /// *inside* the tenant — its metadata holds the connection catalogue — so + /// removing the tenant first would leave nothing to read them through, and + /// the credentials of everybody who was in it would stay behind in + /// credstore. Membership is also the authority for access (ADR-0011 §2): + /// while it exists the organization is still somebody's, and it must be the + /// first thing to stop being true. + /// + /// Deleting a tenant is a soft delete with a retention window in + /// account-management, and it refuses a tenant that still has children — a + /// workspace or a project. That refusal is the right one and is passed + /// through: an organization with work in it is not something to remove by + /// answering one prompt. + /// + /// Idempotent as far as it can be: emptying an organization with no members + /// removes nothing, and account-management returns the existing tombstone + /// for a tenant already deleted. + pub async fn delete(&self, ctx: &SecurityContext, org_id: Uuid) -> Result { + let tenant = self + .am + .get_tenant(ctx, org_id) + .await + .map_err(|e| anyhow!("cannot read organization {org_id}: {e}"))?; + // A workspace and a project are tenants too, and this route must not be + // a way to delete one of those by naming it an organization. + if tenant.tenant_type.as_deref() != Some(ORGANIZATION_TENANT_TYPE) { + return Err(anyhow!("{org_id} is not an organization")); + } + + let evicted = self.evictions.evict_everybody(ctx, org_id).await?; + self.am + .delete_tenant(ctx, org_id) + .await + .map_err(|e| anyhow!("cannot delete organization {org_id}: {e}"))?; + Ok(Deletion { + people: evicted.people, + connections: evicted.connections, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_is_trimmed_and_kept_as_written() { + assert_eq!(clean_name(" Acme ").expect("valid"), "Acme"); + assert_eq!(clean_name("Acme Ltd.").expect("valid"), "Acme Ltd."); + } + + #[test] + fn a_name_that_is_only_space_is_not_a_name() { + assert!(clean_name(" ").is_err()); + assert!(clean_name("").is_err()); + } + + #[test] + fn the_length_limit_counts_characters_not_bytes() { + assert!(clean_name(&"я".repeat(MAX_NAME_LEN)).is_ok()); + assert!(clean_name(&"a".repeat(MAX_NAME_LEN + 1)).is_err()); + } + + #[test] + fn two_organizations_may_share_a_name() { + // Not a uniqueness check anywhere, on purpose: whether somebody wants a + // second organization called the same thing is their business + // (ADR-0018 §5). This test exists so removing that stays a decision. + assert_eq!( + clean_name("Acme").expect("valid"), + clean_name("Acme").expect("valid") + ); + } +} diff --git a/studio-backend/src/studio_authz_plugin.rs b/studio-backend/src/studio_authz_plugin.rs index 8ed5bc0e..bee0a55e 100644 --- a/studio-backend/src/studio_authz_plugin.rs +++ b/studio-backend/src/studio_authz_plugin.rs @@ -25,7 +25,9 @@ //! Patterns (client fetch, `GtsTypeId::new(<&str>)`, `resolve_metadata`) mirror //! the in-crate `connectors` gear, which already reads tenant metadata. -use std::sync::{Arc, OnceLock}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use account_management_sdk::AccountManagementClient; use async_trait::async_trait; @@ -37,12 +39,12 @@ use authz_resolver_sdk::{ use gts::GtsTypeId; use serde::Deserialize; use toolkit::Gear; -use toolkit::client_hub::ClientScope; +use toolkit::client_hub::{ClientHub, ClientScope}; use toolkit::context::GearCtx; use toolkit::gts::PluginV1; use toolkit_security::SecurityContext; use toolkit_security::pep_properties; -use tracing::info; +use tracing::{info, warn}; use types_registry_sdk::{RegisterResult, TypesRegistryClient}; use uuid::Uuid; @@ -102,7 +104,7 @@ impl Gear for StudioAuthZPlugin { RegisterResult::ensure_all_ok(&results)?; let am = ctx.client_hub().get::()?; - let service = Arc::new(Service::new(am)); + let service = Arc::new(Service::new(am, ctx.client_hub())); self.service .set(service.clone()) .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; @@ -151,14 +153,134 @@ struct GrantDef { /* ── Service ── */ +/// How long a person's organization list is reused before it is read again. +/// +/// This runs on every authorization decision, so reading memberships per +/// request would put an indexed `SELECT` in front of every call through the +/// gateway. Memberships change rarely and the window is short, so the cost of +/// the staleness is bounded and stated: a membership granted takes effect +/// within this long, and one revoked stops working within this long — which +/// still satisfies ADR-0011 §7's "without waiting for a new external login". +const MEMBERSHIP_TTL: Duration = Duration::from_secs(10); + +/// A subject's organizations, with when and against which generation they were +/// read. +type MembershipCache = HashMap; + +struct CachedMemberships { + read_at: Instant, + generation: u64, + organizations: Arc>, +} + pub struct Service { am: Arc, + /// Held rather than resolved at construction: `studio-user` publishes the + /// reader in its own `init`, and this plugin does not depend on that gear, + /// so init order guarantees nothing. Looked up once, on first use. + hub: Arc, + organizations: OnceLock>>, + cache: Mutex, } impl Service { #[must_use] - pub fn new(am: Arc) -> Self { - Self { am } + pub fn new(am: Arc, hub: Arc) -> Self { + Self { + am, + hub, + organizations: OnceLock::new(), + cache: Mutex::new(HashMap::new()), + } + } + + /// The reader, looked up once. + /// + /// `None` when `studio-user` is not in this assembly or has no database. The + /// clamp then behaves exactly as it did before memberships existed, which + /// is the safe direction: it reaches less, never more. + fn organization_reader(&self) -> Option<&Arc> { + self.organizations + .get_or_init(|| { + let reader = self + .hub + .get_scoped::( + &ClientScope::gts_id(crate::user_profile::IDENTITY_INSTANCE_ID), + ) + .ok(); + if reader.is_none() { + warn!( + "studio-authz: studio-user is not available — the tenant clamp stays on \ + the token's tenant, so a person cannot reach an organization they are \ + only a member of" + ); + } + reader + }) + .as_ref() + } + + /// Every tenant this request may reach. + /// + /// The tenant the request arrived with, plus the organizations the caller is + /// a member of. The first is kept deliberately: it is what the clamp has + /// always been, so this change can only widen, and nothing that works today + /// stops working — including service accounts, which have a tenant and no + /// memberships. Dropping it is a separate step, after the things that still + /// depend on it are gone (ADR-0018 §3). + async fn reachable_tenants(&self, request: &EvaluationRequest, tid: Uuid) -> Vec { + let mut tids = vec![tid]; + let Some(reader) = self.organization_reader() else { + return tids; + }; + let subject = request.subject.id; + let generation = reader.membership_generation(); + if let Some(cached) = self.cached(subject, generation) { + extend_unique(&mut tids, &cached); + return tids; + } + match reader.organizations_of(&subject.to_string()).await { + Ok(orgs) => { + let orgs = Arc::new(orgs); + if let Ok(mut cache) = self.cache.lock() { + cache.insert( + subject, + CachedMemberships { + read_at: Instant::now(), + generation, + organizations: orgs.clone(), + }, + ); + } + extend_unique(&mut tids, &orgs); + } + Err(error) => { + // Not fatal, and not a denial: the caller keeps the reach they + // had before memberships were consulted. Failing closed here + // would make a database hiccup look like a revoked membership. + warn!(%subject, "studio-authz: cannot read memberships: {error:#}"); + } + } + tids + } + + /// A cached answer is good while it is young *and* nothing has changed + /// membership since it was taken. The generation is what makes a write + /// visible immediately; the age is only a backstop. + fn cached(&self, subject: Uuid, generation: u64) -> Option>> { + let mut cache = self.cache.lock().ok()?; + match cache.get(&subject) { + Some(entry) + if entry.generation == generation && entry.read_at.elapsed() < MEMBERSHIP_TTL => + { + Some(entry.organizations.clone()) + } + Some(_) => { + cache.remove(&subject); + None + } + None => None, + } } fn tenant_of(request: &EvaluationRequest) -> Option { @@ -299,7 +421,10 @@ impl AuthZResolverPluginClient for Service { ) -> Result { let (tid, privilege) = match Plan::for_request(&request) { Plan::Deny => return Ok(deny()), - Plan::Clamp(tid) => return Ok(tenant_clamp(&request, tid)), + Plan::Clamp(tid) => { + let tids = self.reachable_tenants(&request, tid).await; + return Ok(tenant_clamp(&request, &tids)); + } Plan::Roles { tid, privilege } => (tid, privilege), }; @@ -307,10 +432,12 @@ impl AuthZResolverPluginClient for Service { // tenant clamp (behaviour == today, fail-safe). let sec = Service::read_ctx(&request, tid); let Some(cfg) = self.read_access_config(&sec, tid).await else { - return Ok(tenant_clamp(&request, tid)); + let tids = self.reachable_tenants(&request, tid).await; + return Ok(tenant_clamp(&request, &tids)); }; if cfg.model != "roles" { - return Ok(tenant_clamp(&request, tid)); + let tids = self.reachable_tenants(&request, tid).await; + return Ok(tenant_clamp(&request, &tids)); } let subject_id = request.subject.id.to_string(); @@ -353,7 +480,8 @@ impl AuthZResolverPluginClient for Service { // An org-scoped grant carries the privilege across the whole tenant: // that is exactly the tenant clamp (incl. the hierarchy subtree). if org_grant { - return Ok(tenant_clamp(&request, tid)); + let tids = self.reachable_tenants(&request, tid).await; + return Ok(tenant_clamp(&request, &tids)); } // No grant at all → deny. Roles NARROW: tenant membership by itself does @@ -368,7 +496,11 @@ impl AuthZResolverPluginClient for Service { // intersecting with the scope ids can only keep those that live inside // the tenant — a scope id outside the subtree drops out at evaluation, // so a grant can never reach across tenants. - let mut constraints = tenant_constraints(&request, tid); + // The grant's own tenant, not the caller's whole reach: this branch + // narrows to the scopes one grant names, and starting from a wider set + // would let a project-scoped grant pull in an organization the grant + // says nothing about. + let mut constraints = tenant_constraints(&request, &[tid]); for c in &mut constraints { c.predicates.push(Predicate::In(InPredicate::new( pep_properties::OWNER_TENANT_ID, @@ -391,11 +523,21 @@ impl AuthZResolverPluginClient for Service { /// hierarchy subtree branches when the caller supports them. Returned as a bare /// `Vec` so the role path can AND further narrowing into each branch /// (constraints are OR-combined; predicates within one are AND-combined). -fn tenant_constraints(request: &EvaluationRequest, tid: Uuid) -> Vec { +/// The clamp, over every tenant the caller may reach. +/// +/// Constraints in a response are OR-ed and predicates inside one are AND-ed +/// (`authz_resolver_sdk::constraints`), so "any of these tenants" is a list of +/// constraints and nothing more exotic. `InTenantSubtree` carries a single +/// root, which is why the subtree arms are one per tenant rather than one with +/// a list. +/// +/// `tids` is never empty: it always contains the tenant the request arrived +/// with, so this can only ever widen what a caller could already reach. +fn tenant_constraints(request: &EvaluationRequest, tids: &[Uuid]) -> Vec { let mut constraints = vec![Constraint { predicates: vec![Predicate::In(InPredicate::new( pep_properties::OWNER_TENANT_ID, - [tid], + tids.to_vec(), ))], }]; let hierarchy = request @@ -411,11 +553,13 @@ fn tenant_constraints(request: &EvaluationRequest, tid: Uuid) -> Vec .iter() .any(|p| p == prop) { - constraints.push(Constraint { - predicates: vec![Predicate::InTenantSubtree(InTenantSubtreePredicate::new( - prop, tid, - ))], - }); + for tid in tids { + constraints.push(Constraint { + predicates: vec![Predicate::InTenantSubtree( + InTenantSubtreePredicate::new(prop, *tid), + )], + }); + } } } } @@ -423,11 +567,20 @@ fn tenant_constraints(request: &EvaluationRequest, tid: Uuid) -> Vec } /// static-authz behaviour: allow, clamped to the context tenant (+ subtree). -fn tenant_clamp(request: &EvaluationRequest, tid: Uuid) -> EvaluationResponse { +/// Append the ones that are not already there, preserving order. +fn extend_unique(into: &mut Vec, more: &[Uuid]) { + for id in more { + if !into.contains(id) { + into.push(*id); + } + } +} + +fn tenant_clamp(request: &EvaluationRequest, tids: &[Uuid]) -> EvaluationResponse { EvaluationResponse { decision: true, context: EvaluationResponseContext { - constraints: tenant_constraints(request, tid), + constraints: tenant_constraints(request, tids), ..Default::default() }, } @@ -519,6 +672,108 @@ mod tests { "gts.cf.core.users.user.v1~", ]; + const ORG_A: Uuid = Uuid::from_u128(0xa1); + const ORG_B: Uuid = Uuid::from_u128(0xb2); + + /// A request that can express the subtree predicates, so the clamp's + /// hierarchy arms are actually built. + fn hierarchical(resource_type: &str) -> EvaluationRequest { + let mut r = request(resource_type); + r.context.capabilities = vec![Capability::TenantHierarchy]; + r.context.supported_properties = vec![ + pep_properties::OWNER_TENANT_ID.to_string(), + pep_properties::RESOURCE_ID.to_string(), + ]; + r + } + + fn owner_tenant_values(constraints: &[Constraint]) -> Vec { + constraints + .iter() + .flat_map(|c| &c.predicates) + .filter_map(|p| match p { + Predicate::In(inp) => Some(inp), + _ => None, + }) + .flat_map(|inp| inp.values.iter()) + .filter_map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok())) + .collect() + } + + fn subtree_roots(constraints: &[Constraint]) -> Vec { + constraints + .iter() + .flat_map(|c| &c.predicates) + .filter_map(|p| match p { + // The root is carried as a JSON value, so the test reads it the + // same way the PEP compiler does. + Predicate::InTenantSubtree(t) => t + .root_tenant_id + .as_str() + .and_then(|s| Uuid::parse_str(s).ok()), + _ => None, + }) + .collect() + } + + /// The point of reading memberships: a person reaches the organizations + /// they belong to, not only the one their token names. + #[test] + fn the_clamp_covers_every_tenant_the_caller_may_reach() { + let c = tenant_constraints(&hierarchical(STUDIO_RESOURCES[0]), &[TENANT, ORG_A, ORG_B]); + let mut owners = owner_tenant_values(&c); + owners.sort(); + let mut expected = vec![TENANT, ORG_A, ORG_B]; + expected.sort(); + assert_eq!(owners, expected, "every reachable tenant is in the IN arm"); + + // One subtree arm per tenant per supported property: `InTenantSubtree` + // carries a single root, so a set is a list of arms. + let roots = subtree_roots(&c); + for tid in [TENANT, ORG_A, ORG_B] { + assert_eq!( + roots.iter().filter(|r| **r == tid).count(), + 2, + "{tid} needs a subtree arm for the owner tenant and one for the resource" + ); + } + } + + /// With one tenant the clamp is what it always was — this change widens and + /// never narrows, which is what makes it safe to land before the things + /// that still read the token's tenant are gone. + #[test] + fn one_tenant_produces_the_clamp_it_always_did() { + let c = tenant_constraints(&hierarchical(STUDIO_RESOURCES[0]), &[TENANT]); + assert_eq!(owner_tenant_values(&c), vec![TENANT]); + assert_eq!(subtree_roots(&c), vec![TENANT, TENANT]); + } + + /// A gear that cannot express subtrees still gets the flat arm, and it + /// still lists every reachable tenant. + #[test] + fn without_the_hierarchy_capability_the_flat_arm_still_covers_the_set() { + let c = tenant_constraints(&request(STUDIO_RESOURCES[0]), &[TENANT, ORG_A]); + assert!( + subtree_roots(&c).is_empty(), + "no capability, no subtree arms" + ); + let mut owners = owner_tenant_values(&c); + owners.sort(); + let mut expected = vec![TENANT, ORG_A]; + expected.sort(); + assert_eq!(owners, expected); + } + + /// Duplicates never reach the clamp: a person whose token already names one + /// of their organizations gets it once. + #[test] + fn a_tenant_already_present_is_not_added_twice() { + let mut tids = vec![TENANT]; + extend_unique(&mut tids, &[ORG_A, TENANT, ORG_A]); + assert_eq!(tids, vec![TENANT, ORG_A]); + } + /// The read that would recurse must be guarded, or the PDP asks itself /// whether it may ask itself. #[test] diff --git a/studio-backend/src/user_profile/entity.rs b/studio-backend/src/user_profile/entity.rs index 3233c7b3..eb107f17 100644 --- a/studio-backend/src/user_profile/entity.rs +++ b/studio-backend/src/user_profile/entity.rs @@ -93,6 +93,11 @@ pub mod membership { pub user_id: Uuid, pub org_id: Uuid, pub role: String, + /// `active` or `suspended` (ADR-0011 §2). A membership that is not + /// active is still a membership: the row says the person belongs here + /// and what they would hold if reinstated, while granting nothing + /// meanwhile. Leaving deletes the row; suspension does not. + pub status: String, pub source: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, @@ -126,3 +131,42 @@ pub mod alias { pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } + +pub mod invitation { + use sea_orm::entity::prelude::*; + use time::OffsetDateTime; + use toolkit_db::secure::Scopable; + use uuid::Uuid; + + /// A pending membership: an organization, an address, a role, and a secret + /// that turns them into one. + /// + /// The token is stored as a digest, never as itself, so a read of this + /// table yields nothing usable (see `invitations::mint_token`). + #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Scopable)] + #[sea_orm(table_name = "identity_invitation")] + #[secure(tenant_col = "tenant_id", resource_col = "id", no_owner, no_type)] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub org_id: Uuid, + /// Normalized, and the only thing an acceptance is matched against. + pub email: String, + pub role: String, + /// SHA-256 of the token. Unique, so a token identifies one invitation. + pub token_digest: String, + /// The person who sent it. + pub invited_by: Uuid, + pub created_at: OffsetDateTime, + pub expires_at: OffsetDateTime, + /// Set once, by the acceptance. Its presence is what makes an + /// invitation single-use. + pub accepted_at: Option, + pub accepted_by: Option, + } + + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + impl ActiveModelBehavior for ActiveModel {} +} diff --git a/studio-backend/src/user_profile/invitations.rs b/studio-backend/src/user_profile/invitations.rs new file mode 100644 index 00000000..0fc4febd --- /dev/null +++ b/studio-backend/src/user_profile/invitations.rs @@ -0,0 +1,307 @@ +//! Who may accept an invitation, and what an invitation is worth. +//! +//! An invitation is a **bearer secret that becomes a membership**. Everything +//! dangerous about it follows from that: whoever holds the token can join an +//! organization, so the token must be unguessable, usable once, short-lived, +//! and bound to a person the invitation was actually meant for. +//! +//! No IO here, so the rules are stated as tests rather than discovered in +//! production — the same shape as `alias_policy`. + +use anyhow::{Result, anyhow}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// How long an invitation stands before it has to be sent again. +/// +/// Long enough that somebody on holiday is not locked out, short enough that a +/// token forgotten in an inbox is not a standing key to an organization. +pub const VALID_FOR_DAYS: i64 = 14; + +/// Fold an address into the form both sides are compared in. +/// +/// Lowercased and trimmed. Addresses are compared, never displayed back as +/// authority, so the only thing that matters is that the invitation side and +/// the acceptance side fold identically — which is why both go through here. +#[must_use] +pub fn normalize_email(raw: &str) -> String { + raw.trim().to_lowercase() +} + +/// Refuse an address that is not one before it is stored. +/// +/// Deliberately not a full grammar: the address is never sent to, only +/// compared, and a strict parser would refuse valid addresses the realm accepts +/// while adding nothing. What matters is that it is non-empty, has the one +/// shape every address has, and is not long enough to be a payload. +pub fn validate_email(raw: &str) -> Result { + let email = normalize_email(raw); + if email.is_empty() { + return Err(anyhow!("an invitation needs an e-mail address")); + } + if email.len() > 320 { + return Err(anyhow!("that e-mail address is too long")); + } + let mut parts = email.split('@'); + let local = parts.next().unwrap_or_default(); + let domain = parts.next().unwrap_or_default(); + if local.is_empty() || domain.is_empty() || parts.next().is_some() || !domain.contains('.') { + return Err(anyhow!("'{raw}' does not look like an e-mail address")); + } + Ok(email) +} + +/// The roles an invitation may carry. +/// +/// Not `owner`: ownership arises from creating an organization or from an owner +/// handing over deliberately (ADR-0018 §6), and an invitation is neither. An +/// invitation that could mint owners would make a forwarded e-mail a way to take +/// an organization. +pub const INVITABLE_ROLES: [&str; 2] = ["member", "admin"]; + +pub fn validate_role(raw: &str) -> Result { + let role = raw.trim().to_lowercase(); + if INVITABLE_ROLES.contains(&role.as_str()) { + Ok(role) + } else { + Err(anyhow!( + "role must be one of {}", + INVITABLE_ROLES.join(", ") + )) + } +} + +/// A fresh secret and the digest to store beside it. +/// +/// The secret is returned once, to be handed to the invited person; only the +/// digest is kept. An invitation table that could be read back into working +/// tokens would turn a database read into an organization takeover. +/// +/// The secret is two v4 UUIDs — 244 bits from the operating system's generator, +/// so guessing is not an attack, and it needs no dependency this crate does not +/// already have. +#[must_use] +pub fn mint_token() -> (String, String) { + let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let digest = digest_of(&token); + (token, digest) +} + +/// The stored form of a token. +#[must_use] +pub fn digest_of(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.trim().as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Why an invitation cannot be accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// No invitation has that token. Also the answer for one that never + /// existed — the two are not distinguished, because telling them apart + /// would turn this into an oracle for guessing tokens. + Unknown, + Expired, + AlreadyAccepted, + /// The caller's verified address is not the one that was invited. + NotYours, + /// The caller has no verified address at all, so nothing can be matched. + NoVerifiedEmail, +} + +impl Refusal { + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::Unknown => "that invitation does not exist", + Self::Expired => "that invitation has expired — ask for a new one", + Self::AlreadyAccepted => "that invitation has already been used", + Self::NotYours => { + "that invitation was sent to a different address than the one your account has \ + verified" + } + Self::NoVerifiedEmail => { + "your account has no verified e-mail address, so an invitation cannot be matched \ + to you" + } + } + } +} + +/// What an acceptance attempt knows about the invitation it found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pending { + pub email: String, + pub expired: bool, + pub accepted: bool, +} + +/// May this caller accept this invitation? +/// +/// `verified_email` is the address the identity provider vouches for — never +/// the profile address, which the person sets themselves. +/// +/// The order of the checks is deliberate: existence and state are decided +/// before identity, so a caller learns "used" or "expired" about an invitation +/// they hold the token for, and learns nothing at all about one they do not. +pub fn may_accept(found: Option<&Pending>, verified_emails: &[String]) -> Result<(), Refusal> { + let Some(pending) = found else { + return Err(Refusal::Unknown); + }; + if pending.accepted { + return Err(Refusal::AlreadyAccepted); + } + if pending.expired { + return Err(Refusal::Expired); + } + if verified_emails.is_empty() { + return Err(Refusal::NoVerifiedEmail); + } + // Every address the person has verified, not only the one they happen to be + // signed in with: one human holds several logins, and an invitation sent to + // the address on one of them is theirs whichever way they came in today. + if verified_emails + .iter() + .any(|mine| normalize_email(mine) == pending.email) + { + Ok(()) + } else { + Err(Refusal::NotYours) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mail(s: &str) -> String { + s.to_owned() + } + + /// A person whose second login carries the invited address still gets in. + #[test] + fn any_verified_address_of_the_person_matches() { + assert_eq!( + may_accept( + Some(&pending("ada@work.example")), + &[mail("ada@personal.example"), mail("ada@work.example")] + ), + Ok(()) + ); + } + + fn pending(email: &str) -> Pending { + Pending { + email: normalize_email(email), + expired: false, + accepted: false, + } + } + + #[test] + fn the_invited_person_may_accept() { + assert_eq!( + may_accept( + Some(&pending("Ada@Example.COM")), + &[mail("ada@example.com")] + ), + Ok(()) + ); + } + + #[test] + fn somebody_else_holding_the_token_may_not() { + // The whole point of binding an invitation to an address: a forwarded + // e-mail is not a way into an organization. + assert_eq!( + may_accept( + Some(&pending("ada@example.com")), + &[mail("bob@example.com")] + ), + Err(Refusal::NotYours) + ); + } + + #[test] + fn without_a_verified_address_nothing_can_be_matched() { + // The profile address is self-service, so "no verified address" must + // refuse rather than fall back to what the person typed about themselves. + assert_eq!( + may_accept(Some(&pending("ada@example.com")), &[]), + Err(Refusal::NoVerifiedEmail) + ); + } + + #[test] + fn a_used_or_expired_invitation_is_refused_before_identity_is_considered() { + let used = Pending { + accepted: true, + ..pending("ada@example.com") + }; + assert_eq!( + may_accept(Some(&used), &[mail("bob@example.com")]), + Err(Refusal::AlreadyAccepted) + ); + let expired = Pending { + expired: true, + ..pending("ada@example.com") + }; + assert_eq!( + may_accept(Some(&expired), &[mail("bob@example.com")]), + Err(Refusal::Expired) + ); + } + + #[test] + fn an_unknown_token_says_only_that() { + // No distinction between "never existed" and "not yours": the pair + // would let somebody probe for valid tokens. + assert_eq!( + may_accept(None, &[mail("ada@example.com")]), + Err(Refusal::Unknown) + ); + } + + #[test] + fn a_token_is_stored_as_its_digest_and_never_as_itself() { + let (token, digest) = mint_token(); + assert_ne!(token, digest); + assert_eq!(digest_of(&token), digest, "the same token digests the same"); + let (other, _) = mint_token(); + assert_ne!(token, other, "two invitations do not share a token"); + } + + #[test] + fn an_address_is_compared_in_one_form() { + assert_eq!(normalize_email(" Ada@Example.COM "), "ada@example.com"); + assert_eq!( + validate_email(" Ada@Example.com ").expect("valid"), + "ada@example.com" + ); + } + + #[test] + fn something_that_is_not_an_address_is_refused() { + for bad in [ + "", + " ", + "ada", + "ada@", + "@example.com", + "ada@example", + "a@b@c.com", + ] { + assert!(validate_email(bad).is_err(), "{bad:?} must be refused"); + } + } + + #[test] + fn an_invitation_cannot_mint_an_owner() { + // Ownership comes from creating an organization or from an owner + // handing over — never from a link somebody forwarded. + assert!(validate_role("owner").is_err()); + assert_eq!(validate_role(" Member ").expect("valid"), "member"); + assert_eq!(validate_role("admin").expect("valid"), "admin"); + } +} diff --git a/studio-backend/src/user_profile/leaving.rs b/studio-backend/src/user_profile/leaving.rs new file mode 100644 index 00000000..99f54067 --- /dev/null +++ b/studio-backend/src/user_profile/leaving.rs @@ -0,0 +1,263 @@ +//! What must still be true after somebody's standing in an organization +//! changes. +//! +//! One rule, and it is the only thing standing between self-service departure +//! and organizations nobody can administer: **an organization always has an +//! active owner**. ADR-0011 §4 stated it; ADR-0018 §6 gave it a self-service +//! path into it, which is what makes it worth enforcing in one place rather +//! than at each route that could break it. +//! +//! Every way a membership can change asks the same question — leaving, being +//! removed, being demoted, being suspended — so the question is asked about the +//! *result*: here is the room as it would be afterwards, is somebody in it +//! still able to administer it? +//! +//! No IO, so the rule is stated as tests. + +use crate::access_config::ROLE_OWNER; + +/// A membership that grants what it says. +pub const STATUS_ACTIVE: &str = "active"; +/// A membership that still records where somebody belongs and grants nothing +/// while it stands (ADR-0011 §2). +pub const STATUS_SUSPENDED: &str = "suspended"; + +/// The states a membership can be in, as the API accepts them. +pub const STATUSES: [&str; 2] = [STATUS_ACTIVE, STATUS_SUSPENDED]; + +/// One person's standing in an organization, as far as this rule cares. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Member { + pub user_id: String, + pub role: String, + pub status: String, +} + +impl Member { + /// An owner who can act as one. + /// + /// A suspended owner is not one: their membership grants nothing while it + /// stands, so an organization left with only suspended owners is an + /// organization with nobody able to administer it — which is the state this + /// module exists to prevent. + fn is_active_owner(&self) -> bool { + self.role == ROLE_OWNER && self.status == STATUS_ACTIVE + } +} + +/// What somebody's membership would become. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Standing { + pub role: String, + pub status: String, +} + +impl Standing { + /// An ordinary active membership in `role`. + pub fn active(role: &str) -> Self { + Self { + role: role.to_owned(), + status: STATUS_ACTIVE.to_owned(), + } + } +} + +/// Why a change to somebody's membership is refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// They are not in this organization to begin with. + NotAMember, + /// They are the only owner who can act, and the change would leave none. + LastOwner, + /// They are the only person here at all. Leaving would abandon the + /// organization rather than hand it over, so it is deletion that is being + /// asked for — and deletion is a different act with a different + /// confirmation. + LastPerson, +} + +impl Refusal { + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::NotAMember => "you are not a member of that organization", + Self::LastOwner => { + "an organization needs an owner who can act — make somebody else an owner first" + } + Self::LastPerson => { + "you are the only person in that organization, so leaving it would mean deleting \ + it — which is a separate, deliberate act" + } + } + } +} + +/// May `user_id`'s membership become `after`, or end entirely? +/// +/// `after` is `None` for leaving or being removed. `members` is everybody in the +/// organization, including the person in question. +/// +/// The order matters: being the last person is reported ahead of being the last +/// owner, because it is the more useful thing to be told. "Make somebody else an +/// owner first" is not advice a person alone in an organization can act on. +pub fn may_change( + members: &[Member], + user_id: &str, + after: Option<&Standing>, +) -> Result<(), Refusal> { + if !members.iter().any(|m| m.user_id == user_id) { + return Err(Refusal::NotAMember); + } + + // The room as it would be, and then one question asked of it. + let mut afterwards: Vec = members + .iter() + .filter(|m| m.user_id != user_id) + .cloned() + .collect(); + if let Some(standing) = after { + afterwards.push(Member { + user_id: user_id.to_owned(), + role: standing.role.clone(), + status: standing.status.clone(), + }); + } + + if afterwards.is_empty() { + return Err(Refusal::LastPerson); + } + if !afterwards.iter().any(Member::is_active_owner) { + return Err(Refusal::LastOwner); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn member(user_id: &str, role: &str) -> Member { + Member { + user_id: user_id.to_owned(), + role: role.to_owned(), + status: STATUS_ACTIVE.to_owned(), + } + } + + fn suspended(user_id: &str, role: &str) -> Member { + Member { + status: STATUS_SUSPENDED.to_owned(), + ..member(user_id, role) + } + } + + /// Leaving, spelled so the tests read as sentences. + const LEAVE: Option<&Standing> = None; + + const ADA: &str = "ada"; + const BOB: &str = "bob"; + + #[test] + fn an_ordinary_member_may_always_leave() { + let org = [member(ADA, "owner"), member(BOB, "member")]; + assert_eq!(may_change(&org, BOB, LEAVE), Ok(())); + } + + #[test] + fn one_of_two_owners_may_leave() { + let org = [member(ADA, "owner"), member(BOB, "owner")]; + assert_eq!(may_change(&org, ADA, LEAVE), Ok(())); + } + + #[test] + fn the_only_owner_may_not_leave_while_others_remain() { + // The organization would still have people in it, and nobody able to + // administer them. + let org = [member(ADA, "owner"), member(BOB, "member")]; + assert_eq!(may_change(&org, ADA, LEAVE), Err(Refusal::LastOwner)); + } + + #[test] + fn demoting_the_only_owner_is_the_same_refusal_as_removing_them() { + // Otherwise the invariant would be enforced on one route and walked + // around on another. + let org = [member(ADA, "owner"), member(BOB, "member")]; + for role in ["member", "admin"] { + assert_eq!( + may_change(&org, ADA, Some(&Standing::active(role))), + Err(Refusal::LastOwner) + ); + } + } + + #[test] + fn suspending_the_only_owner_is_refused_for_the_same_reason() { + // A suspended membership grants nothing, so this leaves the + // organization exactly as ownerless as removing them would. + let org = [member(ADA, "owner"), member(BOB, "member")]; + let after = Standing { + role: "owner".to_owned(), + status: STATUS_SUSPENDED.to_owned(), + }; + assert_eq!(may_change(&org, ADA, Some(&after)), Err(Refusal::LastOwner)); + } + + #[test] + fn suspending_anybody_else_is_allowed() { + let org = [member(ADA, "owner"), member(BOB, "member")]; + let after = Standing { + role: "member".to_owned(), + status: STATUS_SUSPENDED.to_owned(), + }; + assert_eq!(may_change(&org, BOB, Some(&after)), Ok(())); + } + + #[test] + fn a_suspended_owner_does_not_cover_for_the_active_one() { + // Two owners on paper, one of them suspended: the active one leaving + // would leave nobody who can act. + let org = [member(ADA, "owner"), suspended(BOB, "owner")]; + assert_eq!(may_change(&org, ADA, LEAVE), Err(Refusal::LastOwner)); + } + + #[test] + fn reinstating_that_owner_is_what_unblocks_the_other_one() { + let org = [member(ADA, "owner"), member(BOB, "owner")]; + assert_eq!(may_change(&org, ADA, LEAVE), Ok(())); + } + + #[test] + fn making_somebody_an_owner_is_never_refused() { + let org = [member(ADA, "owner")]; + assert_eq!( + may_change(&org, ADA, Some(&Standing::active("owner"))), + Ok(()) + ); + } + + #[test] + fn the_only_person_is_told_that_rather_than_to_find_an_owner() { + // "Make somebody else an owner first" is not advice somebody alone in + // an organization can act on. + let org = [member(ADA, "owner")]; + assert_eq!(may_change(&org, ADA, LEAVE), Err(Refusal::LastPerson)); + } + + #[test] + fn the_only_person_cannot_suspend_themselves_either() { + // The row would stay and the organization would keep existing with + // nobody able to administer it — the state the rule is about. + let org = [member(ADA, "owner")]; + let after = Standing { + role: "owner".to_owned(), + status: STATUS_SUSPENDED.to_owned(), + }; + assert_eq!(may_change(&org, ADA, Some(&after)), Err(Refusal::LastOwner)); + } + + #[test] + fn somebody_who_is_not_there_is_told_so() { + let org = [member(ADA, "owner")]; + assert_eq!(may_change(&org, BOB, LEAVE), Err(Refusal::NotAMember)); + } +} diff --git a/studio-backend/src/user_profile/migrations.rs b/studio-backend/src/user_profile/migrations.rs index 28efb20b..4c95414d 100644 --- a/studio-backend/src/user_profile/migrations.rs +++ b/studio-backend/src/user_profile/migrations.rs @@ -19,7 +19,11 @@ pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m0001::Migration)] + vec![ + Box::new(m0001::Migration), + Box::new(m0002::Migration), + Box::new(m0003::Migration), + ] } } @@ -105,3 +109,110 @@ CREATE INDEX IF NOT EXISTS idx_identity_alias_user ON identity_alias (user_id); } } } + +mod m0002 { + use toolkit_db::sea_orm_migration::prelude::*; + use toolkit_db::sea_orm_migration::sea_orm; + use toolkit_db::sea_orm_migration::sea_orm::ConnectionTrait; + + const UNSUPPORTED: &str = "studio-user migrations: PostgreSQL only"; + + pub struct Migration; + + impl MigrationName for Migration { + fn name(&self) -> &str { + "m0002_invitation" + } + } + + #[async_trait::async_trait] + impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = match manager.get_database_backend() { + sea_orm::DatabaseBackend::Postgres => { + // The digest is UNIQUE because a token must identify exactly + // one invitation; the index is also the lookup an acceptance + // does. The org index is "what have I sent", which is the + // only other way this table is read. + r" +CREATE TABLE IF NOT EXISTS identity_invitation ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + org_id UUID NOT NULL, + email TEXT NOT NULL, + role TEXT NOT NULL, + token_digest TEXT NOT NULL, + invited_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + accepted_by UUID +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_invitation_token + ON identity_invitation (token_digest); +CREATE INDEX IF NOT EXISTS idx_identity_invitation_org + ON identity_invitation (org_id); +CREATE INDEX IF NOT EXISTS idx_identity_invitation_email + ON identity_invitation (email); + " + } + _ => return Err(DbErr::Custom(UNSUPPORTED.to_owned())), + }; + manager.get_connection().execute_unprepared(sql).await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS identity_invitation;") + .await?; + Ok(()) + } + } +} + +mod m0003 { + use toolkit_db::sea_orm_migration::prelude::*; + use toolkit_db::sea_orm_migration::sea_orm; + use toolkit_db::sea_orm_migration::sea_orm::ConnectionTrait; + + const UNSUPPORTED: &str = "studio-user migrations: PostgreSQL only"; + + pub struct Migration; + + impl MigrationName for Migration { + fn name(&self) -> &str { + "m0003_membership_status" + } + } + + #[async_trait::async_trait] + impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = match manager.get_database_backend() { + sea_orm::DatabaseBackend::Postgres => { + // DEFAULT 'active' rather than a nullable column: every row + // that exists was written when active was the only state + // there was, so that is what it means, and a NULL would + // leave every reader to decide what absence meant. + r" +ALTER TABLE identity_membership + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'; + " + } + _ => return Err(DbErr::Custom(UNSUPPORTED.to_owned())), + }; + manager.get_connection().execute_unprepared(sql).await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("ALTER TABLE identity_membership DROP COLUMN IF EXISTS status;") + .await?; + Ok(()) + } + } +} diff --git a/studio-backend/src/user_profile/mod.rs b/studio-backend/src/user_profile/mod.rs index f7f58a95..5502b630 100644 --- a/studio-backend/src/user_profile/mod.rs +++ b/studio-backend/src/user_profile/mod.rs @@ -14,6 +14,8 @@ mod alias_policy; mod entity; +mod invitations; +mod leaving; mod migrations; mod rest; mod service; @@ -33,8 +35,63 @@ use toolkit_db::DBProvider; use toolkit_security::SecurityContext; use tracing::{info, warn}; +use serde::Deserialize; + use service::IdentityService; +/// What the installation states about itself. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct StudioUserConfig { + /// The sign-in subjects that are platform administrators here. + /// + /// ADR-0011 §4: the first administrator is a deliberately provisioned + /// identity, not the first person to open the portal. Each one named here + /// gets a membership of the platform root at every start, which is what + /// being a platform administrator *is* after ADR-0018 §3. + #[serde(default)] + pub platform_admins: Vec, + + /// What a person gets the first time they are seen, if anything. + /// + /// Absent in the cloud: people arrive with no organization and create one. + /// Set in an installation inside one company, where the deployment is + /// stating *the users of this identity provider are the members of this + /// organization* (ADR-0018 §4). + /// + /// That statement is recorded as a membership row, which is not the same as + /// deriving access from authentication: a row can be revoked — suspending + /// somebody in Studio without removing them from the corporate directory — + /// and it records how it came about. Access that followed the token could do + /// neither. + #[serde(default)] + pub on_first_login: Option, +} + +/// The organization a new person joins, and as what. +#[derive(Debug, Clone, Deserialize)] +pub struct FirstLoginJoin { + #[serde(with = "uuid_text")] + pub organization: uuid::Uuid, + /// `member` unless the installation says otherwise. Never `owner`: + /// ownership is not something a deployment hands to everybody who signs in. + #[serde(default = "default_join_role")] + pub role: String, +} + +fn default_join_role() -> String { + "member".to_owned() +} + +/// A uuid written as a string in YAML. +mod uuid_text { + use serde::{Deserialize, Deserializer}; + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let raw = String::deserialize(d)?; + raw.parse().map_err(serde::de::Error::custom) + } +} + /// Fold an alias key into its stored form. Re-exported because the /// knowledge-graph sync must normalize a login the same way a write did, /// or the lookup misses the row. @@ -145,6 +202,13 @@ pub trait AssignmentRecorder: Send + Sync + 'static { org_id: uuid::Uuid, role: &str, ) -> anyhow::Result<()>; + + /// Record that `subject` created `org_id` and owns it. + /// + /// The role is not a parameter: creating an organization makes you its + /// owner and nothing else, so letting a caller pass a role here would only + /// create a way to get it wrong. + async fn record_creation(&self, subject: &str, org_id: uuid::Uuid) -> anyhow::Result<()>; } #[async_trait] @@ -157,6 +221,88 @@ impl AssignmentRecorder for IdentityService { ) -> anyhow::Result<()> { IdentityService::record_assignment(self, subject, org_id, role).await } + + async fn record_creation(&self, subject: &str, org_id: uuid::Uuid) -> anyhow::Result<()> { + IdentityService::record_creation(self, subject, org_id).await + } +} + +/// Emptying an organization that is being deleted. +/// +/// Separate from [`AssignmentRecorder`] because it is the opposite act with the +/// opposite risk: recording an assignment can be wrong and corrected, while this +/// removes every membership of an organization at once and takes credentials +/// with it. A gear asks for this one deliberately. +/// +/// It does not check the last-owner rule, and that is the point: the rule keeps +/// an organization administrable, and an organization being deleted has nothing +/// left to administer. The authority to delete is checked where deletion is +/// decided — this is the consequence, not the decision. +#[async_trait] +pub trait MembershipEvictor: Send + Sync + 'static { + /// End every membership of `org_id`, removing each person's personal + /// connections in it as they go. + async fn evict_everybody( + &self, + ctx: &SecurityContext, + org_id: uuid::Uuid, + ) -> anyhow::Result; +} + +#[async_trait] +impl MembershipEvictor for IdentityService { + async fn evict_everybody( + &self, + ctx: &SecurityContext, + org_id: uuid::Uuid, + ) -> anyhow::Result { + IdentityService::evict_everybody(self, ctx, org_id).await + } +} + +/// The organizations a sign-in method's person belongs to. +/// +/// Published for the Studio PDP, which has a token subject and needs to know +/// what that person may reach. Deliberately not `PersonResolver`: that one +/// provisions, and an authorization decision must not create a person as a side +/// effect of somebody knocking. +/// +/// Read-only and one subject at a time, like every other interface this gear +/// publishes. +#[async_trait] +pub trait OrganizationReader: Send + Sync + 'static { + /// The organizations the person behind `subject` is a member of. A subject + /// no login knows has none. + async fn organizations_of(&self, subject: &str) -> anyhow::Result>; + + /// Does this subject's person hold a membership of the platform root? + /// + /// One spelling of the rule, so a gear deciding whether somebody is a + /// platform administrator cannot drift from the gear that records it. + async fn is_platform_admin(&self, subject: &str) -> anyhow::Result; + + /// Changes whenever any membership is written anywhere. + /// + /// A caller that caches an answer from `organizations_of` keeps this beside + /// it and throws the answer away when it moves. Without it a cache outlives + /// the write that invalidates it — which is how a person briefly could not + /// finish creating their own organization. + fn membership_generation(&self) -> u64; +} + +#[async_trait] +impl OrganizationReader for IdentityService { + async fn organizations_of(&self, subject: &str) -> anyhow::Result> { + IdentityService::organizations_of(self, subject).await + } + + async fn is_platform_admin(&self, subject: &str) -> anyhow::Result { + IdentityService::is_platform_admin(self, subject).await + } + + fn membership_generation(&self) -> u64 { + service::membership_generation() + } } #[toolkit::gear( @@ -208,11 +354,21 @@ impl Gear for StudioUserGear { ClientScope::gts_id(IDENTITY_INSTANCE_ID), people, ); - let assignments: Arc = svc; + let assignments: Arc = svc.clone(); ctx.client_hub().register_scoped::( ClientScope::gts_id(IDENTITY_INSTANCE_ID), assignments, ); + let evictor: Arc = svc.clone(); + ctx.client_hub().register_scoped::( + ClientScope::gts_id(IDENTITY_INSTANCE_ID), + evictor, + ); + let organizations: Arc = svc; + ctx.client_hub().register_scoped::( + ClientScope::gts_id(IDENTITY_INSTANCE_ID), + organizations, + ); } self.service @@ -260,7 +416,7 @@ impl RestApiCapability for StudioUserGear { // answers 400 only if neither channel is there. let federated = ctx .client_hub() - .get_scoped::( + .get_scoped::( &ClientScope::gts_id(crate::identity_directory::IDP_DIRECTORY_INSTANCE_ID), ) .ok(); @@ -271,6 +427,48 @@ impl RestApiCapability for StudioUserGear { ); } svc.attach_federated(federated); + + // Seeded here rather than in `init` because it writes through the + // same path everything else does and wants the gear fully built. + // Failing to seed is logged, not fatal: an installation that cannot + // reach its database has a larger problem than an unseeded + // administrator, and refusing to boot would hide it. + let cfg = ctx + .config_or_default::() + .unwrap_or_default(); + if let Some(join) = cfg.on_first_login.as_ref() { + if join.role == crate::access_config::ROLE_OWNER { + warn!( + "studio-user: on_first_login.role is `owner` — refusing it. Everybody who \ + signs in would own the organization; set `member` or `admin`." + ); + } else { + info!( + organization = %join.organization, + role = %join.role, + "studio-user: a new person joins this organization on first sight" + ); + svc.set_first_login_join(Some((join.organization, join.role.clone()))); + } + } + let admins = cfg.platform_admins; + if admins.is_empty() { + warn!( + "studio-user: no platform_admins configured. Being a platform administrator \ + is a membership of the platform root now, and nothing seeds one here \ + (ADR-0018 §3) — conflict resolution and the directory's administrative \ + routes have nobody to answer to until such a membership exists." + ); + } + if !admins.is_empty() { + let svc = svc.clone(); + tokio::spawn(async move { + match svc.seed_platform_admins(&admins).await { + Ok(n) => info!("studio-user: {n} platform administrator(s) seeded"), + Err(e) => warn!("studio-user: cannot seed platform administrators: {e:#}"), + } + }); + } } Ok(rest::register_routes(router, openapi, service)) diff --git a/studio-backend/src/user_profile/rest.rs b/studio-backend/src/user_profile/rest.rs index 9bf15384..98079996 100644 --- a/studio-backend/src/user_profile/rest.rs +++ b/studio-backend/src/user_profile/rest.rs @@ -18,14 +18,12 @@ use toolkit_security::SecurityContext; use uuid::Uuid; use super::alias_policy::Confidence; +use super::leaving; use super::service::{ - AliasOutcome, ConfirmReport, IdentityService, LoginView, MembershipView, ProfilePatch, + AliasOutcome, ConfirmReport, IdentityService, LoginView, MembershipView, Offered, ProfilePatch, UserProfile, }; -/// The platform root tenant; a caller acting here is a platform admin. -const PLATFORM_ROOT_TENANT_ID: Uuid = Uuid::from_u128(1); - #[resource_error(gts_id!("cf.studio.user.profile.v1~"))] pub struct UserProfileError; @@ -88,6 +86,10 @@ pub struct OrgMembershipDto { pub user_id: String, pub org_id: String, pub role: String, + /// `active` or `suspended`. A suspended membership grants nothing while it + /// stands — the organization does not appear in what this person may reach + /// — and still records that they belong here and in what role. + pub status: String, pub source: String, pub created_at_epoch_ms: i64, pub updated_at_epoch_ms: i64, @@ -104,6 +106,14 @@ pub struct MembershipListDto { pub struct PutMembershipRequest { /// The role this person holds in THIS organization. pub role: String, + /// `active` (the default) or `suspended`. + /// + /// Suspending is not removing: the row stays, with the role it would come + /// back to. It is the same edit to the same row as changing a role, so it + /// arrives on the same request and passes the same rule — an organization + /// cannot be left with no owner who can act, whichever of the two did it. + #[serde(default)] + pub status: Option, /// How the membership was established: "assignment", "grant", "manual". pub source: Option, } @@ -180,6 +190,59 @@ pub struct ConfirmReportDto { pub refused: Vec, } +#[derive(Debug)] +#[toolkit_macros::api_dto(request)] +pub struct InviteRequest { + /// The address the invitation is bound to. Only somebody whose identity + /// provider has verified this address can accept it. + pub email: String, + /// `member` or `admin`. Not `owner` — ownership is not something a link + /// can confer. + pub role: String, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct InvitationDto { + pub id: String, + pub org_id: String, + pub email: String, + pub role: String, + pub expires_at_epoch_ms: i64, + pub accepted_at_epoch_ms: Option, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct InvitationCreatedDto { + pub invitation: InvitationDto, + /// Shown once and never again — only its digest is stored. Hand it to the + /// person being invited. + pub token: String, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct InvitationListDto { + pub items: Vec, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(request)] +pub struct AcceptInvitationRequest { + /// The token from the invitation message. Send this or `invitation_id`. + #[serde(default)] + pub token: Option, + /// The id of one of the invitations `GET /me/invitations` listed for you. + /// + /// That listing is built by matching invitations to addresses this person + /// has proven, so an id from it carries the same proof the token does — + /// which is what makes accepting from the portal possible at all, since the + /// token is never stored and cannot be shown again. + #[serde(default)] + pub invitation_id: Option, +} + #[derive(Debug)] #[toolkit_macros::api_dto(response)] pub struct AliasPairDto { @@ -242,6 +305,7 @@ fn membership_to_dto(m: MembershipView) -> OrgMembershipDto { user_id: m.user_id, org_id: m.org_id, role: m.role, + status: m.status, source: m.source, created_at_epoch_ms: m.created_at_epoch_ms, updated_at_epoch_ms: m.updated_at_epoch_ms, @@ -271,13 +335,37 @@ fn configured(service: Option>) -> ApiResult ApiResult<()> { - if ctx.subject_tenant_id() != PLATFORM_ROOT_TENANT_ID { - return Err(UserProfileError::permission_denied() +/// Is the caller a platform administrator? +/// +/// One signal: **a membership of the platform root**. That is an answer about +/// the *person*, so it holds however they signed in. +/// +/// The token's tenant used to be accepted as well. It was an answer about one +/// *login*, which made somebody an administrator through one sign-in method and +/// an ordinary member through another — the thing ADR-0018 §3 set out to end. +/// This is that migration's third step: the installation seeds its +/// administrators (`platform_admins`), the backfill wrote the rows for the +/// identities that already carried the `tenant_id` attribute, and with the +/// reading gone the attribute has no organizational purpose left — which is +/// what ADR-0016's follow-up was waiting for. +async fn is_platform_admin(ctx: &SecurityContext, service: &Arc) -> bool { + service + .is_platform_admin(&ctx.subject_id().to_string()) + .await + .unwrap_or(false) +} + +async fn require_platform_admin( + ctx: &SecurityContext, + service: &Arc, +) -> ApiResult<()> { + if is_platform_admin(ctx, service).await { + Ok(()) + } else { + Err(UserProfileError::permission_denied() .with_reason("PLATFORM_ADMIN_REQUIRED") - .create()); + .create()) } - Ok(()) } /// A membership write needs authority over that organization: its owner has it, @@ -292,8 +380,7 @@ async fn require_org_authority( service: &Arc, org_id: Uuid, ) -> ApiResult<()> { - if ctx.subject_tenant_id() == PLATFORM_ROOT_TENANT_ID || service.is_org_owner(ctx, org_id).await - { + if is_platform_admin(ctx, service).await || service.is_org_owner(ctx, org_id).await { Ok(()) } else { Err(UserProfileError::permission_denied() @@ -302,6 +389,23 @@ async fn require_org_authority( } } +/// Turn a last-owner refusal into an answer the caller can act on. +/// +/// `NotAMember` is a 404 — there is no membership at that address to end. The +/// other two are 400: the request is well-formed and the caller is allowed, but +/// the organization would be left without an owner, and the message says what +/// to do first. +fn refused(refusal: leaving::Refusal, user_id: &str, org_id: &str) -> CanonicalError { + match refusal { + leaving::Refusal::NotAMember => UserProfileError::not_found(refusal.message()) + .with_resource(format!("{user_id}@{org_id}")) + .create(), + _ => UserProfileError::invalid_argument() + .with_constraint(refusal.message()) + .create(), + } +} + fn parse_org(org_id: &str) -> ApiResult { Uuid::parse_str(org_id).map_err(|_| { UserProfileError::invalid_argument() @@ -374,6 +478,16 @@ async fn get_my_logins( Ok(Json(LoginListDto { items })) } +/// What leaving took with it. +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct LeaveResultDto { + /// How many of the leaver's personal connections were removed along with + /// the membership. Reported rather than silent: it is their credentials + /// that just disappeared, and they should be told how many. + pub connections_removed: u32, +} + async fn get_my_memberships( Extension(ctx): Extension, Extension(service): Extension>>, @@ -395,8 +509,8 @@ async fn get_user( Extension(service): Extension>>, Path(user_id): Path, ) -> ApiResult> { - require_platform_admin(&ctx)?; let service = configured(service)?; + require_platform_admin(&ctx, &service).await?; let profile = service .get_profile(&user_id) .await @@ -414,8 +528,8 @@ async fn get_user_memberships( Extension(service): Extension>>, Path(user_id): Path, ) -> ApiResult> { - require_platform_admin(&ctx)?; let service = configured(service)?; + require_platform_admin(&ctx, &service).await?; let items = service .list_memberships(&user_id) .await @@ -435,27 +549,75 @@ async fn put_membership( let service = configured(service)?; let org = parse_org(&org_id)?; require_org_authority(&ctx, &service, org).await?; + let status = req.status.as_deref().unwrap_or(leaving::STATUS_ACTIVE); + if !leaving::STATUSES.contains(&status) { + return Err(UserProfileError::invalid_argument() + .with_constraint(format!( + "status must be one of {}", + leaving::STATUSES.join(", ") + )) + .create()); + } + let after = leaving::Standing { + role: req.role.clone(), + status: status.to_owned(), + }; let source = req.source.unwrap_or_else(|| "manual".to_string()); - let membership = service - .record_membership(&user_id, &org_id, &req.role, &source) + // A role change or a suspension can remove the last owner who can act just + // as surely as a removal can, so both go through the one gate. + match service + .set_membership_standing(&user_id, &org_id, &after, &source) .await - .map_err(internal)?; - Ok(Json(membership_to_dto(membership))) + .map_err(internal)? + { + Ok(membership) => Ok(Json(membership_to_dto(membership))), + Err(refusal) => Err(refused(refusal, &user_id, &org_id)), + } } async fn delete_membership( Extension(ctx): Extension, Extension(service): Extension>>, Path((user_id, org_id)): Path<(String, String)>, -) -> ApiResult { +) -> ApiResult> { let service = configured(service)?; let org = parse_org(&org_id)?; require_org_authority(&ctx, &service, org).await?; - service - .remove_membership(&user_id, &org_id) + // Removed by an owner or walking out on their own, the departure is the + // same one: the same invariant holds it back, and the same credentials go + // with it. + leave(&ctx, &service, &user_id, org).await +} + +async fn leave_my_organization( + Extension(ctx): Extension, + Extension(service): Extension>>, + Path(org_id): Path, +) -> ApiResult> { + let service = configured(service)?; + let org = parse_org(&org_id)?; + // No authority gate: leaving is nobody's permission to give. The last-owner + // rule is the only thing that can hold it back. + let user_id = caller_user_id(&ctx, &service).await?; + leave(&ctx, &service, &user_id, org).await +} + +async fn leave( + ctx: &SecurityContext, + service: &Arc, + user_id: &str, + org: Uuid, +) -> ApiResult> { + match service + .leave_organization(ctx, user_id, org) .await - .map_err(internal)?; - Ok(StatusCode::NO_CONTENT) + .map_err(internal)? + { + Ok(connections_removed) => Ok(Json(LeaveResultDto { + connections_removed: connections_removed as u32, + })), + Err(refusal) => Err(refused(refusal, user_id, &org.to_string())), + } } fn parse_confidence(raw: Option<&str>) -> ApiResult { @@ -521,8 +683,8 @@ async fn add_alias( Path(user_id): Path, Json(req): Json, ) -> ApiResult> { - require_platform_admin(&ctx)?; let service = configured(service)?; + require_platform_admin(&ctx, &service).await?; let confidence = parse_confidence(req.confidence.as_deref())?; // Through the same gate as the self-service path: an admin writing on // somebody's behalf must not be able to silently take an identity another @@ -592,13 +754,133 @@ async fn confirm_my_aliases( Ok(Json(confirm_report_dto(report))) } +fn invitation_dto(r: super::service::InvitationRecord) -> InvitationDto { + InvitationDto { + id: r.id, + org_id: r.org_id, + email: r.email, + role: r.role, + expires_at_epoch_ms: r.expires_at_epoch_ms, + accepted_at_epoch_ms: r.accepted_at_epoch_ms, + } +} + +async fn invite_to_organization( + Extension(ctx): Extension, + Extension(service): Extension>>, + Path(org_id): Path, + Json(req): Json, +) -> ApiResult> { + let service = configured(service)?; + let org = parse_org(&org_id)?; + require_org_authority(&ctx, &service, org).await?; + let inviter = caller_user_id(&ctx, &service).await?; + let (record, token) = service + .invite(org, &inviter, &req.email, &req.role) + .await + .map_err(invalid)?; + Ok(Json(InvitationCreatedDto { + invitation: invitation_dto(record), + token, + })) +} + +async fn list_organization_invitations( + Extension(ctx): Extension, + Extension(service): Extension>>, + Path(org_id): Path, +) -> ApiResult> { + let service = configured(service)?; + let org = parse_org(&org_id)?; + require_org_authority(&ctx, &service, org).await?; + let items = service + .invitations_of(org) + .await + .map_err(internal)? + .into_iter() + .map(invitation_dto) + .collect(); + Ok(Json(InvitationListDto { items })) +} + +async fn revoke_invitation( + Extension(ctx): Extension, + Extension(service): Extension>>, + Path((org_id, invitation_id)): Path<(String, String)>, +) -> ApiResult { + let service = configured(service)?; + let org = parse_org(&org_id)?; + require_org_authority(&ctx, &service, org).await?; + if service + .revoke_invitation(org, &invitation_id) + .await + .map_err(internal)? + { + Ok(StatusCode::NO_CONTENT) + } else { + Err( + UserProfileError::not_found("no such invitation in this organization") + .with_resource(invitation_id) + .create(), + ) + } +} + +async fn my_invitations( + Extension(ctx): Extension, + Extension(service): Extension>>, +) -> ApiResult> { + let service = configured(service)?; + let user_id = caller_user_id(&ctx, &service).await?; + let emails = service.verified_emails(&user_id).await.map_err(internal)?; + let items = service + .invitations_waiting_for(&emails) + .await + .map_err(internal)? + .into_iter() + .map(invitation_dto) + .collect(); + Ok(Json(InvitationListDto { items })) +} + +async fn accept_invitation( + Extension(ctx): Extension, + Extension(service): Extension>>, + Json(req): Json, +) -> ApiResult> { + let service = configured(service)?; + let user_id = caller_user_id(&ctx, &service).await?; + let emails = service.verified_emails(&user_id).await.map_err(internal)?; + let offered = match (req.token.as_deref(), req.invitation_id.as_deref()) { + (Some(token), None) => Offered::Token(token), + (None, Some(id)) => Offered::Id(id), + _ => { + return Err(UserProfileError::invalid_argument() + .with_constraint("send exactly one of token or invitation_id") + .create()); + } + }; + match service + .accept_invitation(&user_id, &offered, &emails) + .await + .map_err(internal)? + { + Ok(membership) => Ok(Json(membership_to_dto(membership))), + // Every refusal is the caller's to act on and none of them reveals + // anything about an invitation they do not hold. + Err(refusal) => Err(UserProfileError::invalid_argument() + .with_constraint(refusal.message()) + .create()), + } +} + async fn merge_users( Extension(ctx): Extension, Extension(service): Extension>>, Json(req): Json, ) -> ApiResult> { - require_platform_admin(&ctx)?; let service = configured(service)?; + require_platform_admin(&ctx, &service).await?; let result = service .merge(&req.from_user_id, &req.into_user_id) .await @@ -615,8 +897,8 @@ async fn resolve_identity( Extension(service): Extension>>, Json(req): Json, ) -> ApiResult> { - require_platform_admin(&ctx)?; let service = configured(service)?; + require_platform_admin(&ctx, &service).await?; let user_id = service .resolve_or_provision(&req.provider, &req.subject, None, None, true) .await @@ -760,10 +1042,15 @@ pub fn register_routes( let router = OperationBuilder::put("/studio-user/v1/users/{user_id}/memberships/{org_id}") .operation_id("studio_user.put_membership") - .summary("Set a user's role in an organization (organization owner)") + .summary("Set a user's role or standing in an organization (organization owner)") .description( - "Records the role a person holds in one organization. Gated on being an OWNER of that \ - organization; role lives on the membership, never on the profile.", + "Records the role a person holds in one organization, and whether that membership \ + currently applies. Gated on being an OWNER of that organization; role lives on the \ + membership, never on the profile. `status: suspended` stops the membership granting \ + anything — the organization disappears from what that person may reach — while \ + keeping the record of where they belong and what they would come back to; leaving \ + and removal delete the row instead. Refused where it would leave the organization \ + with no owner able to act, whether by demotion or by suspension.", ) .tag("StudioUser") .authenticated() @@ -788,9 +1075,11 @@ pub fn register_routes( .operation_id("studio_user.delete_membership") .summary("Remove a user's membership in an organization (organization owner)") .description( - "Removes a user's membership in an organization, and the role that \ - came with it. The user record and their other memberships are \ - untouched. Organization owners only.", + "Removes a user's membership in an organization, and the role that came with it, \ + along with the personal connections they created there. The user record and their \ + other memberships are untouched. Organization owners only, and refused where it \ + would leave the organization without an owner — the same rule that applies when \ + somebody leaves of their own accord.", ) .tag("StudioUser") .authenticated() @@ -798,10 +1087,43 @@ pub fn register_routes( .path_param("user_id", "Canonical Studio user id") .path_param("org_id", "Organization (tenant) id") .handler(delete_membership) - .no_content_response(StatusCode::NO_CONTENT, "Membership removed") + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Membership removed, and what went with it", + ) .error_400(openapi) .error_401(openapi) .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi) + .layer(Extension(service.clone())); + + let router = OperationBuilder::delete("/studio-user/v1/me/memberships/{org_id}") + .operation_id("studio_user.leave_organization") + .summary("Leave an organization") + .description( + "Ends the caller's own membership. Nobody's permission is needed for it, and the \ + only thing that refuses it is the rule that an organization always has an owner: \ + its only owner is told to appoint another first, and its only person is told that \ + leaving would mean deleting the organization, which is a separate act. Documents, \ + projects and authorship stay with the organization; the caller's personal \ + connections do not, and the response says how many were removed.", + ) + .tag("StudioUser") + .authenticated() + .require_license_features::([]) + .path_param("org_id", "Organization (tenant) id") + .handler(leave_my_organization) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Left, and what went with it", + ) + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) .error_500(openapi) .register(router, openapi) .layer(Extension(service.clone())); @@ -956,6 +1278,110 @@ pub fn register_routes( .register(router, openapi) .layer(Extension(service.clone())); + let router = OperationBuilder::post("/studio-user/v1/organizations/{org_id}/invitations") + .operation_id("studio_user.invite_to_organization") + .summary("Invite an address into an organization") + .description( + "An owner or a platform administrator invites somebody by e-mail. The token comes \ + back once and is stored only as a digest, so it cannot be shown again — hand it to \ + the person being invited. It expires, it works once, and it can only be accepted by \ + somebody whose identity provider has verified that address: a forwarded invitation \ + is not a way into an organization. The role may be `member` or `admin`; ownership \ + is not something a link can confer.", + ) + .tag("StudioUser") + .authenticated() + .require_license_features::([]) + .path_param("org_id", "Organization tenant id") + .json_request::(openapi, "Who to invite, and as what") + .handler(invite_to_organization) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The invitation and its one-time token", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::get("/studio-user/v1/organizations/{org_id}/invitations") + .operation_id("studio_user.list_organization_invitations") + .summary("List an organization's invitations") + .description("Tokens are never included — only their digests are stored.") + .tag("StudioUser") + .authenticated() + .require_license_features::([]) + .path_param("org_id", "Organization tenant id") + .handler(list_organization_invitations) + .json_response_with_schema::(openapi, StatusCode::OK, "Invitations") + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::delete( + "/studio-user/v1/organizations/{org_id}/invitations/{invitation_id}", + ) + .operation_id("studio_user.revoke_invitation") + .summary("Withdraw an invitation") + .description( + "Deletes it outright rather than marking it spent: an invitation nobody may use again has nothing left to record, and a withdrawn row left behind would keep appearing in the organization's list. Answers 404 when there is no such invitation in this organization — the organization is part of the lookup, so one organization's owner cannot withdraw another's.", + ) + .tag("StudioUser") + .authenticated() + .require_license_features::([]) + .path_param("org_id", "Organization tenant id") + .path_param("invitation_id", "Invitation id") + .handler(revoke_invitation) + .no_content_response(StatusCode::NO_CONTENT, "Withdrawn") + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::get("/studio-user/v1/me/invitations") + .operation_id("studio_user.my_invitations") + .summary("Invitations waiting for the signed-in person") + .description( + "Matched against every address this person's identity provider has verified, across \ + all of their sign-in methods — an invitation sent to the address on one login is \ + theirs whichever way they signed in today. The profile e-mail is not used: it is \ + self-service, so believing it would let anybody claim any invitation.", + ) + .tag("StudioUser") + .authenticated() + .require_license_features::([]) + .handler(my_invitations) + .json_response_with_schema::(openapi, StatusCode::OK, "Invitations") + .error_401(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::post("/studio-user/v1/me/invitations/accept") + .operation_id("studio_user.accept_invitation") + .summary("Accept an invitation and become a member") + .description( + "Single use, decided by the database rather than by a check followed by a write, so \ + two acceptances racing produce one member and one refusal.", + ) + .tag("StudioUser") + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "The invitation token") + .handler(accept_invitation) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The membership it produced", + ) + .error_400(openapi) + .error_401(openapi) + .error_500(openapi) + .register(router, openapi); + OperationBuilder::post("/studio-user/v1/merge") .operation_id("studio_user.merge_users") .summary("Merge one user into another (platform admin)") diff --git a/studio-backend/src/user_profile/service.rs b/studio-backend/src/user_profile/service.rs index a2ee2cfb..def4ca35 100644 --- a/studio-backend/src/user_profile/service.rs +++ b/studio-backend/src/user_profile/service.rs @@ -9,26 +9,23 @@ //! platform action. use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use account_management_sdk::AccountManagementClient; use anyhow::{Result, anyhow}; -use gts::GtsTypeId; -use serde::Deserialize; use toolkit_security::SecurityContext; use uuid::Uuid; use super::alias_policy::{ Confidence, Decision, Held, ProofOwner, decide, displaced_a_proof, proof_owner, }; +use super::invitations; +use super::leaving; use super::store::IdentityStore; use crate::connectors::service::ConnectorService; -use crate::identity_directory::FederatedIdentityReader; - -/// AM tenant-metadata type holding an organization's access config (the same -/// document the Studio PDP reads). -const ACCESS_METADATA_TYPE: &str = "gts.cf.core.am.tenant_metadata.v1~cf.studio.access.config.v1~"; +use crate::identity_directory::IdpDirectoryReader; /// A connection whose scope makes it a team or bot credential rather than the /// caller's own. `ConnectionScope::Personal` serialises as this string. @@ -38,6 +35,66 @@ const PERSONAL_SCOPE: &str = "personal"; /// `manual` (an operator using the REST route directly). const SOURCE_ASSIGNMENT: &str = "assignment"; +/// `membership.source` for the owner row a person gets by creating the +/// organization. The third way in, beside being assigned and being added by +/// hand — and the one an owner's member list should be able to tell apart. +const SOURCE_CREATION: &str = "creation"; + +/// `membership.source` for a row an accepted invitation produced. The fourth +/// and last way in, and the one an owner most wants to be able to tell apart. +const SOURCE_INVITATION: &str = "invitation"; + +/// What emptying an organization removed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Eviction { + pub people: usize, + pub connections: usize, +} + +/// How an acceptance names the invitation it is taking. +#[derive(Debug, Clone, Copy)] +pub enum Offered<'a> { + /// The token from the invitation message. Proof in itself. + Token(&'a str), + /// The id from this person's own waiting list, which the server built by + /// matching invitations to addresses they have proven. + Id(&'a str), +} + +/// `membership.source` for a row the installation seeded from configuration. +const SOURCE_BOOTSTRAP: &str = "bootstrap"; + +/// `membership.source` for a row written because somebody signed in for the +/// first time into a deployment that says its IdP's users are its members. +const SOURCE_FIRST_LOGIN: &str = "first_login"; + +/// The tenant every organization hangs under, and the one whose membership +/// makes somebody a platform administrator. +pub const PLATFORM_ROOT_TENANT_ID: Uuid = Uuid::from_u128(1); + +/// Bumped by every write that changes who belongs where. +/// +/// Consumers that cache a person's organizations — the Studio PDP does, because +/// it is asked on every request — read this to know their copy is stale. A +/// counter rather than a per-person signal on purpose: memberships change +/// rarely, the whole cache is small, and one atomic load is cheaper than +/// keeping per-subject invalidation correct. +/// +/// It exists because of a bug this found: creating an organization writes the +/// membership and then the owner grant, and the grant write is authorized by a +/// clamp that had already cached "this person belongs to nothing". The creator +/// could not finish creating their own organization until the cache expired. +static MEMBERSHIP_GENERATION: AtomicU64 = AtomicU64::new(0); + +/// The current membership generation. See [`MEMBERSHIP_GENERATION`]. +pub fn membership_generation() -> u64 { + MEMBERSHIP_GENERATION.load(Ordering::Acquire) +} + +fn memberships_changed() { + MEMBERSHIP_GENERATION.fetch_add(1, Ordering::AcqRel); +} + /// Provider tag for a sign-in method minted through Studio's own Keycloak /// realm. Every bearer the platform authenticates carries a subject from there, /// so this is the provider a caller's `login` row is found under. @@ -82,6 +139,9 @@ pub struct MembershipView { pub user_id: String, pub org_id: String, pub role: String, + /// `active` or `suspended`. A suspended membership grants nothing while it + /// stands and is still a record of where somebody belongs (ADR-0011 §2). + pub status: String, pub source: String, pub created_at_epoch_ms: i64, pub updated_at_epoch_ms: i64, @@ -97,6 +157,24 @@ pub struct AliasRecord { pub added_at_epoch_ms: i64, } +/// A pending membership. +/// +/// `token_digest` is write-only from the service's point of view: it is set when +/// the invitation is made and compared when one is accepted, and never read back +/// out to anybody. +#[derive(Clone, Debug)] +pub struct InvitationRecord { + pub id: String, + pub org_id: String, + pub email: String, + pub role: String, + pub token_digest: String, + pub invited_by: String, + pub created_at_epoch_ms: i64, + pub expires_at_epoch_ms: i64, + pub accepted_at_epoch_ms: Option, +} + /// A patch to a profile; `None` fields are left untouched. #[derive(Clone, Debug, Default)] pub struct ProfilePatch { @@ -114,25 +192,6 @@ pub struct MergeResult { pub memberships_moved: usize, } -// ── Access config (subset; mirrors the PDP's view) ─────────────────────────── - -#[derive(Debug, Clone, Deserialize, Default)] -struct AccessConfig { - #[serde(default)] - grants: Vec, -} -#[derive(Debug, Clone, Deserialize)] -struct GrantDef { - #[serde(rename = "subjectType")] - subject_type: String, - #[serde(rename = "subjectId")] - subject_id: String, - #[serde(rename = "roleKey")] - role_key: String, - #[serde(rename = "scopeType")] - scope_type: String, -} - // ── Service ────────────────────────────────────────────────────────────────── pub struct IdentityService { @@ -145,7 +204,10 @@ pub struct IdentityService { connectors: OnceLock>>, /// The IdP proof channel, attached in the same phase and for the same /// reason. `Some(None)` means Keycloak admin is unconfigured. - federated: OnceLock>>, + federated: OnceLock>>, + /// The organization a person joins the first time they are seen, if the + /// installation says there is one. + first_login_join: OnceLock>, } impl IdentityService { @@ -155,6 +217,7 @@ impl IdentityService { am, connectors: OnceLock::new(), federated: OnceLock::new(), + first_login_join: OnceLock::new(), } } @@ -167,12 +230,17 @@ impl IdentityService { let _ = self.connectors.set(connectors); } + /// Tell the service which organization a new person joins, if any. + pub fn set_first_login_join(&self, join: Option<(Uuid, String)>) { + let _ = self.first_login_join.set(join); + } + /// Hand the service its view of the IdP's brokered logins. /// /// Separate from `new` for the same reason as `attach_connectors`: the /// directory gear is a separate gear, and the REST phase is the first point /// where it is known to have registered. - pub fn attach_federated(&self, federated: Option>) { + pub fn attach_federated(&self, federated: Option>) { let _ = self.federated.set(federated); } @@ -181,23 +249,9 @@ impl IdentityService { /// per-org authority gate: no platform-wide admin needed, and it is scoped /// to the one organization. pub async fn is_org_owner(&self, ctx: &SecurityContext, org_id: Uuid) -> bool { - let subject = ctx.subject_id().to_string(); - let cfg = match self - .am - .resolve_metadata(ctx, org_id, GtsTypeId::new(ACCESS_METADATA_TYPE)) + crate::access_config::read(self.am.as_ref(), ctx, org_id) .await - { - Ok(Some(entry)) => { - serde_json::from_value::(entry.value).unwrap_or_default() - } - _ => return false, - }; - cfg.grants.iter().any(|g| { - g.subject_type == "member" - && g.subject_id == subject - && g.role_key == "owner" - && g.scope_type == "org" - }) + .grants_ownership_to(&ctx.subject_id().to_string()) } /// The canonical person behind an authenticated caller, provisioning on @@ -271,6 +325,27 @@ impl IdentityService { linked_at_epoch_ms: now, }; self.store.upsert_login(&login).await?; + + // The deployment's statement that its identity provider's users are the + // members of one organization (ADR-0018 §4), made true here — at the one + // moment a person begins to exist. Recorded as a row, so it can be + // revoked later without touching the corporate directory, and so it + // remembers how it came about. + // + // Not fatal: a person who exists but has not joined yet is a person the + // next request can still join, whereas failing here would leave them + // unable to sign in at all. + if let Some(Some((org_id, role))) = self.first_login_join.get() + && let Err(error) = self + .record_membership(&user_id, &org_id.to_string(), role, SOURCE_FIRST_LOGIN) + .await + { + tracing::warn!( + user = %user_id, + organization = %org_id, + "studio-user: could not join the new person to the configured organization: {error:#}" + ); + } Ok(user_id) } @@ -568,7 +643,7 @@ impl IdentityService { async fn confirm_from_idp( store: &dyn IdentityStore, user_id: &str, - federated: &dyn FederatedIdentityReader, + federated: &dyn IdpDirectoryReader, report: &mut ConfirmReport, ) -> Result<()> { for login in store.logins_of(user_id).await? { @@ -640,12 +715,28 @@ impl IdentityService { /// Record (upsert) a person's membership in an organization with the role /// held there. Role lives on the membership, never on the profile. + /// + /// Active: every path that records a membership is recording one that + /// applies. Suspension is a later, deliberate edit through + /// `set_membership_standing`, never something a write arrives already in. pub async fn record_membership( &self, user_id: &str, org_id: &str, role: &str, source: &str, + ) -> Result { + self.write_membership(user_id, org_id, &leaving::Standing::active(role), source) + .await + } + + /// Record a membership in a given standing — role and status together. + async fn write_membership( + &self, + user_id: &str, + org_id: &str, + standing: &leaving::Standing, + source: &str, ) -> Result { if self.store.get_user(user_id).await?.is_none() { return Err(anyhow!("user {user_id} does not exist")); @@ -654,7 +745,8 @@ impl IdentityService { let view = MembershipView { user_id: user_id.to_owned(), org_id: org_id.to_owned(), - role: role.to_owned(), + role: standing.role.clone(), + status: standing.status.clone(), source: source.to_owned(), // On an update the stored created_at is preserved (the store's // conflict update excludes it); this value seeds a first insert. @@ -662,6 +754,7 @@ impl IdentityService { updated_at_epoch_ms: now, }; self.store.upsert_membership(&view).await?; + memberships_changed(); Ok(view) } @@ -681,9 +774,369 @@ impl IdentityService { Ok(()) } + /// Record that `subject` created `org_id` and therefore owns it. + /// + /// Separate from [`Self::record_assignment`] only in what it records as the + /// source: ownership that arises from creating an organization did not come + /// from an operator, and an owner reading their member list should see the + /// difference (ADR-0018 §2). + pub async fn record_creation(&self, subject: &str, org_id: Uuid) -> Result<()> { + let user_id = self + .resolve_or_provision(PROVIDER_KEYCLOAK, subject, None, None, true) + .await?; + self.record_membership( + &user_id, + &org_id.to_string(), + crate::access_config::ROLE_OWNER, + SOURCE_CREATION, + ) + .await?; + Ok(()) + } + + /// Is the person behind `subject` a platform administrator? + /// + /// The question is "do they hold a membership of the platform root", not + /// "what does their token say". A token names the tenant of *one login*, so + /// reading administrative rights from it made a person an administrator + /// through one sign-in method and an ordinary member through another + /// (ADR-0018 §3). + /// + /// This is the half of that ADR that replaces the token reading. The + /// callers still accept the old signal as well while the migration runs — + /// see the note on each one. + pub async fn is_platform_admin(&self, subject: &str) -> Result { + Ok(self + .organizations_of(subject) + .await? + .contains(&PLATFORM_ROOT_TENANT_ID)) + } + + /// Seed the memberships that make the configured identities administrators. + /// + /// Idempotent, and run at every start: an installation states who its + /// administrators are, and the row that makes it true is written from that + /// statement rather than from whoever happens to carry an attribute. + /// + /// Without this there is a lockout waiting at the end of the migration: once + /// the token signal is removed, a deployment whose administrators were only + /// ever administrators *by token* would have none, and no way to make one. + pub async fn seed_platform_admins(&self, subjects: &[String]) -> Result { + let mut seeded = 0; + for subject in subjects { + let subject = subject.trim(); + if subject.is_empty() { + continue; + } + let user_id = self + .resolve_or_provision(PROVIDER_KEYCLOAK, subject, None, None, true) + .await?; + self.record_membership( + &user_id, + &PLATFORM_ROOT_TENANT_ID.to_string(), + crate::access_config::ROLE_OWNER, + SOURCE_BOOTSTRAP, + ) + .await?; + seeded += 1; + } + Ok(seeded) + } + + /// The organizations the person behind `subject` is a member of. + /// + /// Never provisions: a subject nobody has seen is a subject with no + /// memberships, and minting a person for one during an authorization + /// decision would create people out of traffic. + pub async fn organizations_of(&self, subject: &str) -> Result> { + let Some(user_id) = self.resolve_subject(PROVIDER_KEYCLOAK, subject).await? else { + return Ok(Vec::new()); + }; + Ok(self + .store + .memberships_of(&user_id) + .await? + .into_iter() + // A suspended membership records where somebody belongs and grants + // nothing while it stands, so it must not appear here: this answer + // is what the PDP clamps on and what decides administrative rights. + .filter(|m| m.status == leaving::STATUS_ACTIVE) + .filter_map(|m| Uuid::parse_str(&m.org_id).ok()) + .collect()) + } + + /// Invite an address into an organization. + /// + /// Returns the token **once**. It is not stored and cannot be shown again: + /// only its digest is kept, so a later read of the table yields nothing + /// that works. + pub async fn invite( + &self, + org_id: Uuid, + inviter: &str, + email: &str, + role: &str, + ) -> Result<(InvitationRecord, String)> { + let email = invitations::validate_email(email)?; + let role = invitations::validate_role(role)?; + let (token, digest) = invitations::mint_token(); + let now = now_ms(); + let record = InvitationRecord { + id: Uuid::new_v4().to_string(), + org_id: org_id.to_string(), + email, + role, + token_digest: digest, + invited_by: inviter.to_owned(), + created_at_epoch_ms: now, + expires_at_epoch_ms: now + invitations::VALID_FOR_DAYS * 24 * 60 * 60 * 1000, + accepted_at_epoch_ms: None, + }; + self.store.insert_invitation(&record).await?; + Ok((record, token)) + } + + /// What an organization has outstanding. + pub async fn invitations_of(&self, org_id: Uuid) -> Result> { + self.store.invitations_of_org(&org_id.to_string()).await + } + + /// Withdraw one. Returns whether there was one to withdraw. + pub async fn revoke_invitation(&self, org_id: Uuid, id: &str) -> Result { + self.store.delete_invitation(id, &org_id.to_string()).await + } + + /// The invitations waiting for a verified address. + /// + /// One row per invitation waiting for any address this person has verified. + pub async fn invitations_waiting_for( + &self, + verified_emails: &[String], + ) -> Result> { + let mut out = Vec::new(); + for email in verified_emails { + for invitation in self + .store + .invitations_for_email(&invitations::normalize_email(email)) + .await? + { + if !out.iter().any(|i: &InvitationRecord| i.id == invitation.id) { + out.push(invitation); + } + } + } + Ok(out) + } + + /// Every address the identity provider vouches for, across all of this + /// person's realm logins. + /// + /// Empty when the directory is not configured — which refuses an + /// acceptance rather than falling back to the profile address. The profile + /// address is self-service, so believing it here would let anybody claim + /// any invitation by typing the address it was sent to. + pub async fn verified_emails(&self, user_id: &str) -> Result> { + let Some(Some(directory)) = self.federated.get() else { + return Ok(Vec::new()); + }; + let mut found = Vec::new(); + for login in self.store.logins_of(user_id).await? { + if login.provider != PROVIDER_KEYCLOAK { + continue; + } + if let Some(email) = directory.verified_email(&login.subject).await? + && !found.contains(&email) + { + found.push(email); + } + } + Ok(found) + } + + /// Accept an invitation and become a member. + /// + /// `verified_email` is what the identity provider vouches for; `None` means + /// it vouches for nothing, which refuses. The membership is written only + /// after the database has confirmed that this call is the one that took the + /// invitation, so a race produces one member and one refusal rather than + /// two members. + pub async fn accept_invitation( + &self, + user_id: &str, + offered: &Offered<'_>, + verified_emails: &[String], + ) -> Result> { + let found = match offered { + Offered::Token(token) => { + let digest = invitations::digest_of(token); + self.store.find_invitation_by_digest(&digest).await? + } + // No weaker: the same verified-address check decides both, and the + // id was only ever learned from a listing that had already applied + // it. What the token adds is a way in for somebody the listing + // cannot reach — an address the provider vouches for but this + // person has not signed in with yet. + Offered::Id(id) => self.store.find_invitation_by_id(id).await?, + }; + let pending = found.as_ref().map(|r| invitations::Pending { + email: r.email.clone(), + expired: r.expires_at_epoch_ms <= now_ms(), + accepted: r.accepted_at_epoch_ms.is_some(), + }); + if let Err(refusal) = invitations::may_accept(pending.as_ref(), verified_emails) { + return Ok(Err(refusal)); + } + let record = found.expect("checked above"); + if !self.store.accept_invitation(&record.id, user_id).await? { + // Somebody else took it between the read and the write. + return Ok(Err(invitations::Refusal::AlreadyAccepted)); + } + let membership = self + .record_membership(user_id, &record.org_id, &record.role, SOURCE_INVITATION) + .await?; + Ok(Ok(membership)) + } + + /// Everybody in one organization, so a caller can see the room before + /// changing who is in it. + pub async fn members_of(&self, org_id: &str) -> Result> { + self.store.memberships_in_org(org_id).await + } + + /// May this membership end, or become `after`? + /// + /// One gate for leaving, for being removed, for being demoted and for being + /// suspended — otherwise the rule would hold on one route and be walked + /// around on another. + pub async fn may_change_membership( + &self, + user_id: &str, + org_id: &str, + after: Option<&leaving::Standing>, + ) -> Result> { + let members: Vec = self + .members_of(org_id) + .await? + .into_iter() + .map(|m| leaving::Member { + user_id: m.user_id, + role: m.role, + status: m.status, + }) + .collect(); + Ok(leaving::may_change(&members, user_id, after)) + } + + /// Set somebody's role and status in one organization, subject to the rule. + /// + /// The one write behind both "change their role" and "suspend them": they + /// are the same edit to the same row, and splitting them would be two ways + /// to reach a state only one of them checked. + pub async fn set_membership_standing( + &self, + user_id: &str, + org_id: &str, + after: &leaving::Standing, + source: &str, + ) -> Result> { + // Somebody being added is not a member yet, and that is not a reason to + // refuse adding them — every other refusal is about the room they would + // leave behind and applies. + if let Err(refusal) = self + .may_change_membership(user_id, org_id, Some(after)) + .await? + && refusal != leaving::Refusal::NotAMember + { + return Ok(Err(refusal)); + } + Ok(Ok(self + .write_membership(user_id, org_id, after, source) + .await?)) + } + + /// Leave an organization: the membership ends, and the leaver's own + /// credentials go with them. + /// + /// Access goes; authorship does not. Documents, projects and workspaces + /// belong to the organization and stay, and attribution in the knowledge + /// graph stays with the person who earned it — history is not rewritten + /// because somebody left (ADR-0018 §6). + /// + /// What does leave with them is every *personal* connection they created + /// here. Without that the organization keeps a working credential of + /// somebody no longer in it, and under ADR-0012 it keeps their proof of + /// controlling that external account too. + pub async fn leave_organization( + &self, + ctx: &SecurityContext, + user_id: &str, + org_id: Uuid, + ) -> Result> { + let org = org_id.to_string(); + if let Err(refusal) = self.may_change_membership(user_id, &org, None).await? { + return Ok(Err(refusal)); + } + self.store.delete_membership(user_id, &org).await?; + memberships_changed(); + + // After the membership, not before: the credentials are the tidy-up, + // and leaving somebody a member while their connections disappear would + // be the worse of the two half-states. + let removed = match self.connectors.get().and_then(Option::as_ref) { + // This gear *is* the person resolver, so it hands itself over + // rather than looking one up. + Some(connectors) => connectors + .delete_personal_of(ctx, org_id, self, user_id) + .await + .unwrap_or_else(|error| { + tracing::warn!( + user = %user_id, + organization = %org_id, + "studio-user: left the organization but could not remove their personal \ + connections: {error:#}" + ); + 0 + }), + None => 0, + }; + Ok(Ok(removed)) + } + /// Remove a person's membership in an organization. - pub async fn remove_membership(&self, user_id: &str, org_id: &str) -> Result<()> { - self.store.delete_membership(user_id, org_id).await + /// End every membership of one organization, and take the personal + /// connections with them. + /// + /// Not `leave_organization` in a loop: the last-owner rule exists to keep an + /// organization administrable, and an organization that is being deleted has + /// nothing left to administer. Refusing here would make the rule the reason + /// an organization can never be disposed of. + /// + /// Ordered the way leaving is, and for the same reason: each person's + /// credentials go after their membership, so a failure part-way leaves + /// people out rather than leaving people in with their credentials gone. + pub async fn evict_everybody(&self, ctx: &SecurityContext, org_id: Uuid) -> Result { + let org = org_id.to_string(); + let mut evicted = Eviction::default(); + for member in self.store.memberships_in_org(&org).await? { + self.store.delete_membership(&member.user_id, &org).await?; + evicted.people += 1; + if let Some(connectors) = self.connectors.get().and_then(Option::as_ref) { + match connectors + .delete_personal_of(ctx, org_id, self, &member.user_id) + .await + { + Ok(n) => evicted.connections += n, + Err(error) => tracing::warn!( + user = %member.user_id, + organization = %org_id, + "studio-user: could not remove a member's personal connections while \ + emptying the organization: {error:#}" + ), + } + } + } + memberships_changed(); + Ok(evicted) } /// Merge `from_user` into `into_user`: repoint every login, alias and @@ -730,6 +1183,9 @@ impl IdentityService { source.merged_into = Some(into_user.to_owned()); source.updated_at_epoch_ms = now_ms(); self.store.upsert_user(&source).await?; + // A merge repoints memberships, so anything caching where this person + // may go is now wrong. + memberships_changed(); Ok(result) } } @@ -934,6 +1390,9 @@ mod idp_channel_tests { async fn memberships_of(&self, _user_id: &str) -> Result> { unimplemented!("not on the ceremony's path") } + async fn memberships_in_org(&self, _org_id: &str) -> Result> { + unimplemented!("not on the ceremony's path") + } async fn delete_membership(&self, _user_id: &str, _org_id: &str) -> Result<()> { unimplemented!("not on the ceremony's path") } @@ -946,6 +1405,27 @@ mod idp_channel_tests { async fn delete_alias(&self, _kind: &str, _external_id: &str) -> Result<()> { unimplemented!("not on the ceremony's path") } + async fn insert_invitation(&self, _i: &InvitationRecord) -> Result<()> { + unimplemented!("not on the ceremony's path") + } + async fn find_invitation_by_id(&self, _id: &str) -> Result> { + unimplemented!("not on the ceremony's path") + } + async fn find_invitation_by_digest(&self, _d: &str) -> Result> { + unimplemented!("not on the ceremony's path") + } + async fn invitations_of_org(&self, _org: &str) -> Result> { + unimplemented!("not on the ceremony's path") + } + async fn invitations_for_email(&self, _email: &str) -> Result> { + unimplemented!("not on the ceremony's path") + } + async fn accept_invitation(&self, _id: &str, _user: &str) -> Result { + unimplemented!("not on the ceremony's path") + } + async fn delete_invitation(&self, _id: &str, _org: &str) -> Result { + unimplemented!("not on the ceremony's path") + } } /// The IdP, answering per subject. Records which subjects were asked about. @@ -955,7 +1435,7 @@ mod idp_channel_tests { } #[async_trait::async_trait] - impl FederatedIdentityReader for Broker { + impl IdpDirectoryReader for Broker { async fn federated_accounts(&self, subject: &str) -> anyhow::Result> { self.asked.lock().expect("lock").push(subject.to_owned()); Ok(self @@ -965,6 +1445,10 @@ mod idp_channel_tests { .map(|(_, accounts)| accounts.clone()) .unwrap_or_default()) } + + async fn verified_email(&self, _subject: &str) -> anyhow::Result> { + unimplemented!("not on the ceremony's path") + } } fn account(provider: &str, user_name: &str) -> FederatedAccount { diff --git a/studio-backend/src/user_profile/store.rs b/studio-backend/src/user_profile/store.rs index 9a62c928..3f0baf5a 100644 --- a/studio-backend/src/user_profile/store.rs +++ b/studio-backend/src/user_profile/store.rs @@ -12,12 +12,14 @@ use async_trait::async_trait; use sea_orm::{ActiveValue, ColumnTrait, Condition, EntityTrait, QueryFilter}; use time::OffsetDateTime; use toolkit_db::DBProvider; -use toolkit_db::secure::{SecureDeleteExt, SecureEntityExt, SecureInsertExt, SecureOnConflict}; +use toolkit_db::secure::{ + SecureDeleteExt, SecureEntityExt, SecureInsertExt, SecureOnConflict, SecureUpdateExt, +}; use toolkit_security::AccessScope; use uuid::Uuid; use super::entity::{self, ROOT_TENANT}; -use super::service::{AliasRecord, LoginView, MembershipView, UserProfile}; +use super::service::{AliasRecord, InvitationRecord, LoginView, MembershipView, UserProfile}; fn scope() -> AccessScope { AccessScope::for_tenant(ROOT_TENANT) @@ -44,6 +46,9 @@ pub(crate) trait IdentityStore: Send + Sync { async fn logins_of(&self, user_id: &str) -> Result>; async fn upsert_membership(&self, m: &MembershipView) -> Result<()>; async fn memberships_of(&self, user_id: &str) -> Result>; + /// Everybody in one organization. The last-owner rule needs to see the + /// whole room, not one person's side of it. + async fn memberships_in_org(&self, org_id: &str) -> Result>; async fn delete_membership(&self, user_id: &str, org_id: &str) -> Result<()>; async fn upsert_alias(&self, alias: &AliasRecord) -> Result<()>; async fn aliases_of(&self, user_id: &str) -> Result>; @@ -57,6 +62,30 @@ pub(crate) trait IdentityStore: Send + Sync { /// list before writing any of it. async fn find_aliases(&self, kind: &str, external_ids: &[String]) -> Result>; async fn delete_alias(&self, kind: &str, external_id: &str) -> Result<()>; + + async fn insert_invitation(&self, invitation: &InvitationRecord) -> Result<()>; + /// The invitation a token identifies, whatever state it is in. + /// + /// State is not filtered here on purpose: the policy decides what "expired" + /// and "used" mean to a caller, and a store that hid them would make those + /// two indistinguishable from "no such invitation". + async fn find_invitation_by_digest(&self, digest: &str) -> Result>; + /// The invitation with this id, whatever state it is in. + /// + /// The other way in, for a person the server has already matched to an + /// invitation by a verified address — they never saw the token, and the + /// listing that showed it to them proved as much as the token would. + async fn find_invitation_by_id(&self, id: &str) -> Result>; + async fn invitations_of_org(&self, org_id: &str) -> Result>; + /// Pending, unexpired invitations for one address. + async fn invitations_for_email(&self, email: &str) -> Result>; + /// Mark one accepted, but only if it is still open. + /// + /// Returns whether this call was the one that took it. Single use has to be + /// decided by the database, not by a check followed by a write: two + /// acceptances racing would both pass the check. + async fn accept_invitation(&self, id: &str, user_id: &str) -> Result; + async fn delete_invitation(&self, id: &str, org_id: &str) -> Result; } // ── conversions (row -> view) ───────────────────────────────────────────── @@ -87,6 +116,7 @@ fn membership_to_view(m: entity::membership::Model) -> MembershipView { user_id: m.user_id.to_string(), org_id: m.org_id.to_string(), role: m.role, + status: m.status, source: m.source, created_at_epoch_ms: to_ms(m.created_at), updated_at_epoch_ms: to_ms(m.updated_at), @@ -115,6 +145,20 @@ impl PgStore { } } +fn invitation_to_view(m: entity::invitation::Model) -> InvitationRecord { + InvitationRecord { + id: m.id.to_string(), + org_id: m.org_id.to_string(), + email: m.email, + role: m.role, + token_digest: m.token_digest, + invited_by: m.invited_by.to_string(), + created_at_epoch_ms: to_ms(m.created_at), + expires_at_epoch_ms: to_ms(m.expires_at), + accepted_at_epoch_ms: m.accepted_at.map(to_ms), + } +} + #[async_trait] impl IdentityStore for PgStore { async fn find_login(&self, provider: &str, subject: &str) -> Result> { @@ -250,6 +294,7 @@ impl IdentityStore for PgStore { user_id: ActiveValue::Set(uid), org_id: ActiveValue::Set(org), role: ActiveValue::Set(m.role.clone()), + status: ActiveValue::Set(m.status.clone()), source: ActiveValue::Set(m.source.clone()), created_at: ActiveValue::Set(from_ms(m.created_at_epoch_ms)), updated_at: ActiveValue::Set(from_ms(m.updated_at_epoch_ms)), @@ -259,6 +304,7 @@ impl IdentityStore for PgStore { ]) .update_columns([ entity::membership::Column::Role, + entity::membership::Column::Status, entity::membership::Column::Source, entity::membership::Column::UpdatedAt, ]) @@ -290,6 +336,22 @@ impl IdentityStore for PgStore { .collect()) } + async fn memberships_in_org(&self, org_id: &str) -> Result> { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + Ok(entity::membership::Entity::find() + .secure() + .scope_with(&scope()) + .filter(Condition::all().add(entity::membership::Column::OrgId.eq(parse_uuid(org_id)?))) + .all(&conn) + .await? + .into_iter() + .map(membership_to_view) + .collect()) + } + async fn delete_membership(&self, user_id: &str, org_id: &str) -> Result<()> { let conn = self .db @@ -419,4 +481,145 @@ impl IdentityStore for PgStore { .await?; Ok(()) } + + async fn insert_invitation(&self, invitation: &InvitationRecord) -> Result<()> { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + let am = entity::invitation::ActiveModel { + id: ActiveValue::Set(parse_uuid(&invitation.id)?), + tenant_id: ActiveValue::Set(ROOT_TENANT), + org_id: ActiveValue::Set(parse_uuid(&invitation.org_id)?), + email: ActiveValue::Set(invitation.email.clone()), + role: ActiveValue::Set(invitation.role.clone()), + token_digest: ActiveValue::Set(invitation.token_digest.clone()), + invited_by: ActiveValue::Set(parse_uuid(&invitation.invited_by)?), + created_at: ActiveValue::Set(from_ms(invitation.created_at_epoch_ms)), + expires_at: ActiveValue::Set(from_ms(invitation.expires_at_epoch_ms)), + accepted_at: ActiveValue::Set(None), + accepted_by: ActiveValue::Set(None), + }; + entity::invitation::Entity::insert(am) + .secure() + .scope_unchecked(&scope()) + .map_err(|e| anyhow!("invitation insert scope: {e}"))? + .exec(&conn) + .await?; + Ok(()) + } + + async fn find_invitation_by_id(&self, id: &str) -> Result> { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + Ok(entity::invitation::Entity::find() + .secure() + .scope_with(&scope()) + .filter(Condition::all().add(entity::invitation::Column::Id.eq(parse_uuid(id)?))) + .one(&conn) + .await? + .map(invitation_to_view)) + } + + async fn find_invitation_by_digest(&self, digest: &str) -> Result> { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + Ok(entity::invitation::Entity::find() + .secure() + .scope_with(&scope()) + .filter(Condition::all().add(entity::invitation::Column::TokenDigest.eq(digest))) + .one(&conn) + .await? + .map(invitation_to_view)) + } + + async fn invitations_of_org(&self, org_id: &str) -> Result> { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + Ok(entity::invitation::Entity::find() + .secure() + .scope_with(&scope()) + .filter(Condition::all().add(entity::invitation::Column::OrgId.eq(parse_uuid(org_id)?))) + .all(&conn) + .await? + .into_iter() + .map(invitation_to_view) + .collect()) + } + + async fn invitations_for_email(&self, email: &str) -> Result> { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + Ok(entity::invitation::Entity::find() + .secure() + .scope_with(&scope()) + .filter( + Condition::all() + .add(entity::invitation::Column::Email.eq(email)) + .add(entity::invitation::Column::AcceptedAt.is_null()) + .add(entity::invitation::Column::ExpiresAt.gt(OffsetDateTime::now_utc())), + ) + .all(&conn) + .await? + .into_iter() + .map(invitation_to_view) + .collect()) + } + + async fn accept_invitation(&self, id: &str, user_id: &str) -> Result { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + // `accepted_at IS NULL` in the WHERE is what makes this single-use: the + // database decides who took it, so two acceptances racing cannot both + // win. + let result = entity::invitation::Entity::update_many() + .secure() + .scope_with(&scope()) + .filter( + Condition::all() + .add(entity::invitation::Column::Id.eq(parse_uuid(id)?)) + .add(entity::invitation::Column::AcceptedAt.is_null()), + ) + .col_expr( + entity::invitation::Column::AcceptedAt, + sea_orm::sea_query::Expr::value(OffsetDateTime::now_utc()), + ) + .col_expr( + entity::invitation::Column::AcceptedBy, + sea_orm::sea_query::Expr::value(parse_uuid(user_id)?), + ) + .exec(&conn) + .await?; + Ok(result.rows_affected > 0) + } + + async fn delete_invitation(&self, id: &str, org_id: &str) -> Result { + let conn = self + .db + .conn() + .map_err(|e| anyhow!("identity db connect: {e}"))?; + // The organization is in the filter, not only checked beforehand: it is + // what stops one organization's owner revoking another's invitation. + let result = entity::invitation::Entity::delete_many() + .secure() + .scope_with(&scope()) + .filter( + Condition::all() + .add(entity::invitation::Column::Id.eq(parse_uuid(id)?)) + .add(entity::invitation::Column::OrgId.eq(parse_uuid(org_id)?)), + ) + .exec(&conn) + .await?; + Ok(result.rows_affected > 0) + } } diff --git a/studio-frontend/src-app/app/api/IdentityApiService.ts b/studio-frontend/src-app/app/api/IdentityApiService.ts index 9b8c8b1b..dc7bbfef 100644 --- a/studio-frontend/src-app/app/api/IdentityApiService.ts +++ b/studio-frontend/src-app/app/api/IdentityApiService.ts @@ -17,7 +17,7 @@ import { RestProtocol, RestMockPlugin, } from '@gears-frontx/react'; -import type { MembershipList } from './types'; +import type { InvitationList, Membership, MembershipList } from './types'; import { identityMockMap } from './mocks'; export const IDENTITY_API_BASE_URL = '/cf/studio-user/v1'; @@ -46,4 +46,26 @@ export class IdentityApiService extends BaseApiService { */ readonly myMemberships = this.protocol(RestEndpointProtocol).query('/me/memberships'); + + /** + * Invitations waiting for this person, matched to the addresses they have + * proven — never to one they typed. + * + * This is how somebody with no organization gets one without an administrator + * in the loop (ADR-0018 §2): the invitation was addressed to them, so it is + * theirs to accept. + */ + readonly myInvitations = + this.protocol(RestEndpointProtocol).query('/me/invitations'); + + /** + * Accept one, by the token that came with it. + * + * The membership it returns is the answer — the shell reloads its context + * from it rather than guessing what changed. + */ + readonly acceptInvitation = this.protocol(RestEndpointProtocol).mutation< + Membership, + { token: string } | { invitation_id: string } + >('POST', '/me/invitations/accept'); } diff --git a/studio-frontend/src-app/app/api/OrganizationsApiService.ts b/studio-frontend/src-app/app/api/OrganizationsApiService.ts new file mode 100644 index 00000000..1a5bc5ea --- /dev/null +++ b/studio-frontend/src-app/app/api/OrganizationsApiService.ts @@ -0,0 +1,60 @@ +/** + * Organizations Domain - API Service + * + * Creating an organization, and asking whether this installation allows it + * (`studio-organizations`). + * + * Separate from `AccountsApiService` even though an organization *is* an + * account-management tenant: creating one is three writes in two systems — the + * tenant, the creator's owner membership and the owner grant the authorization + * policy reads — and the gear is what keeps them together (ADR-0018 §2). A + * portal that created the tenant itself would produce an organization nobody + * owns. + */ + +import { + BaseApiService, + RestEndpointProtocol, + RestProtocol, + RestMockPlugin, +} from '@gears-frontx/react'; +import type { Organization, OrganizationCapabilities } from './types'; +import { organizationsMockMap } from './mocks'; + +export const ORGANIZATIONS_API_BASE_URL = '/cf/studio-organizations/v1'; + +export class OrganizationsApiService extends BaseApiService { + constructor() { + const restProtocol = new RestProtocol({ timeout: 30000 }); + const restEndpoints = new RestEndpointProtocol(restProtocol); + + super({ baseURL: ORGANIZATIONS_API_BASE_URL }, restProtocol, restEndpoints); + + this.registerPlugin( + restProtocol, + new RestMockPlugin({ mockMap: organizationsMockMap, delay: 100 }) + ); + } + + /** + * Whether a person may create an organization here. + * + * Read before offering the control rather than after: an installation inside + * one company joins people to the organization it already has, and a create + * button that answers 403 teaches somebody that the product is broken. + */ + readonly capabilities = + this.protocol(RestEndpointProtocol).query('/capabilities'); + + /** + * Create an organization and become its owner. + * + * `organization_id` finishes one a previous attempt left half-created; the + * error that reports it carries the id, and every write is idempotent, so + * repeating is safe. + */ + readonly create = this.protocol(RestEndpointProtocol).mutation< + Organization, + { name: string; organization_id?: string } + >('POST', '/organizations'); +} diff --git a/studio-frontend/src-app/app/api/index.ts b/studio-frontend/src-app/app/api/index.ts index 78cfdc3e..bec8ce46 100644 --- a/studio-frontend/src-app/app/api/index.ts +++ b/studio-frontend/src-app/app/api/index.ts @@ -6,15 +6,23 @@ export { AccountsApiService, ACCOUNTS_API_BASE_URL } from './AccountsApiService'; export { IdentityApiService, IDENTITY_API_BASE_URL } from './IdentityApiService'; export { + OrganizationsApiService, + ORGANIZATIONS_API_BASE_URL, +} from './OrganizationsApiService'; +export { + type Invitation, + type InvitationList, type Me, type Membership, type MembershipList, + type Organization, + type OrganizationCapabilities, type Page, type Tenant, TENANT_TYPES, PLATFORM_ROOT_TENANT_ID, } from './types'; -export { accountsMockMap, identityMockMap } from './mocks'; +export { accountsMockMap, identityMockMap, organizationsMockMap } from './mocks'; export { StudioEventsApiService, STUDIO_EVENTS_API_BASE_URL, diff --git a/studio-frontend/src-app/app/api/mocks.ts b/studio-frontend/src-app/app/api/mocks.ts index 5e5bf45d..e4a32950 100644 --- a/studio-frontend/src-app/app/api/mocks.ts +++ b/studio-frontend/src-app/app/api/mocks.ts @@ -9,7 +9,15 @@ */ import type { MockMap } from '@gears-frontx/react'; -import type { Me, MembershipList, Page, Tenant } from './types'; +import type { + InvitationList, + Me, + MembershipList, + Organization, + OrganizationCapabilities, + Page, + Tenant, +} from './types'; import { TENANT_TYPES } from './types'; const HOME_TENANT_ID = '00000000-0000-0000-0000-0000000000aa'; @@ -70,8 +78,28 @@ export const identityMockMap: MockMap = { user_id: '00000000-0000-0000-0000-0000000000f1', org_id: HOME_TENANT_ID, role: 'owner', + status: 'active', source: 'assignment', }, ], }), + + // Nothing waiting: the ordinary case for somebody who already has an + // organization, and the one the shell has to render without looking empty. + 'GET /cf/studio-user/v1/me/invitations': (): InvitationList => ({ items: [] }), +}; + +/** + * Organizations mock map + * Keys are full URL patterns (including the /cf/studio-organizations/v1 baseURL) + */ +export const organizationsMockMap: MockMap = { + 'GET /cf/studio-organizations/v1/capabilities': (): OrganizationCapabilities => ({ + self_service: true, + }), + + 'POST /cf/studio-organizations/v1/organizations': (body): Organization => ({ + id: '00000000-0000-0000-0000-0000000000c1', + name: (body as { name?: string } | undefined)?.name ?? 'New organization', + }), }; diff --git a/studio-frontend/src-app/app/api/types.ts b/studio-frontend/src-app/app/api/types.ts index 4eefc7d2..c0b1ac31 100644 --- a/studio-frontend/src-app/app/api/types.ts +++ b/studio-frontend/src-app/app/api/types.ts @@ -62,9 +62,53 @@ export interface Membership { user_id: string; org_id: string; role: string; + /** + * `active` or `suspended`. A suspended membership grants nothing while it + * stands, so it never reaches this list — the shell reads the field to say + * what it is looking at, not to decide access. + */ + status: string; source: string; } export interface MembershipList { items: Membership[]; } + +/** + * An invitation waiting for the signed-in person + * (GET /cf/studio-user/v1/me/invitations). + * + * Matched to them by a verified address, never by one they typed: the token is + * what accepts it, and the list only ever shows invitations already addressed + * to an address this person has proven (ADR-0018 §2). + */ +export interface Invitation { + id: string; + org_id: string; + email: string; + role: string; + expires_at_epoch_ms: number; +} + +export interface InvitationList { + items: Invitation[]; +} + +/** + * What this installation lets people do with organizations + * (GET /cf/studio-organizations/v1/capabilities). + * + * `self_service` is false in an installation inside one company, where the + * organization already exists and people are joined to it — so the screen + * offers waiting rather than a control that answers 403 (ADR-0018 §4). + */ +export interface OrganizationCapabilities { + self_service: boolean; +} + +/** An organization as `studio-organizations` returns it. */ +export interface Organization { + id: string; + name: string; +} diff --git a/studio-frontend/src-app/app/layout/OrganizationAccessGate.test.tsx b/studio-frontend/src-app/app/layout/OrganizationAccessGate.test.tsx new file mode 100644 index 00000000..53a2afed --- /dev/null +++ b/studio-frontend/src-app/app/layout/OrganizationAccessGate.test.tsx @@ -0,0 +1,116 @@ +/** + * What somebody with no organization is offered. + * + * The rule under test: this state is one they can leave on their own (ADR-0018 + * §2) — by creating an organization, or by accepting an invitation already + * addressed to them — and what they are offered is what the installation + * actually allows. Offering creation where creation is refused teaches somebody + * the product is broken; withholding it where it is allowed leaves them waiting + * for an administrator this ADR exists to remove from the path. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, findByText, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const { mockEventBus, mockHas, mockGetService } = vi.hoisted(() => ({ + mockEventBus: { emit: vi.fn() }, + mockHas: vi.fn(), + mockGetService: vi.fn(), +})); + +vi.mock('@gears-frontx/react', async (importOriginal) => ({ + ...(await importOriginal()), + useAppSelector: () => ({ access: 'unassigned' }), + eventBus: mockEventBus, + apiRegistry: { has: mockHas, getService: mockGetService }, +})); + +import { IdentityApiService, OrganizationsApiService } from '@/app/api'; +import { OrganizationAccessGate } from './OrganizationAccessGate'; + +const INVITATION = { + id: 'inv-1', + org_id: '00000000-0000-0000-0000-0000000000b1', + email: 'ada@example.test', + role: 'member', + expires_at_epoch_ms: 4_102_444_800_000, +}; + +function services({ + selfService = true, + invitations = [] as (typeof INVITATION)[], +} = {}) { + const create = { fetch: vi.fn().mockResolvedValue({ id: 'org-1', name: 'Acme' }) }; + const acceptInvitation = { fetch: vi.fn().mockResolvedValue({ org_id: INVITATION.org_id }) }; + const organizations = { + capabilities: { fetch: vi.fn().mockResolvedValue({ self_service: selfService }) }, + create, + }; + const identity = { + myInvitations: { fetch: vi.fn().mockResolvedValue({ items: invitations }) }, + acceptInvitation, + }; + mockHas.mockReturnValue(true); + mockGetService.mockImplementation((service: unknown) => { + if (service === OrganizationsApiService) return organizations; + if (service === IdentityApiService) return identity; + return undefined; + }); + return { create, acceptInvitation }; +} + +describe('OrganizationAccessGate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('offers creation where the installation allows it', async () => { + services({ selfService: true }); + render(); + expect(await screen.findByPlaceholderText('Organization name')).toBeTruthy(); + }); + + it('offers waiting, not a control that would be refused, where it does not', async () => { + services({ selfService: false }); + render(); + // The waiting copy is what appears; the create control never does. + await screen.findByText(/ask a studio administrator/i); + expect(screen.queryByPlaceholderText('Organization name')).toBeNull(); + }); + + it('creates the organization and asks the shell to resolve its context again', async () => { + const { create } = services({ selfService: true }); + render(); + const input = await screen.findByPlaceholderText('Organization name'); + fireEvent.change(input, { target: { value: ' Acme ' } }); + fireEvent.click(screen.getByText('Create')); + await waitFor(() => expect(create.fetch).toHaveBeenCalledWith({ name: 'Acme' })); + // Whatever just became true is true on the server; re-reading it is how + // this screen goes away. + expect(mockEventBus.emit).toHaveBeenCalledWith('app/context/fetch'); + }); + + it('accepts a waiting invitation by its id, never by a token it was never shown', async () => { + const { acceptInvitation } = services({ invitations: [INVITATION] }); + render(); + fireEvent.click(await screen.findByText('Accept')); + await waitFor(() => + expect(acceptInvitation.fetch).toHaveBeenCalledWith({ invitation_id: 'inv-1' }) + ); + expect(mockEventBus.emit).toHaveBeenCalledWith('app/context/fetch'); + }); + + it('passes a refusal through rather than replacing it with something vaguer', async () => { + const { create } = services({ selfService: true }); + create.fetch.mockRejectedValueOnce(new Error('an organization needs a name')); + const { container } = render(); + const input = await screen.findByPlaceholderText('Organization name'); + fireEvent.change(input, { target: { value: 'Acme' } }); + fireEvent.click(screen.getByText('Create')); + expect(await findByText(container, 'an organization needs a name')).toBeTruthy(); + }); +}); diff --git a/studio-frontend/src-app/app/layout/OrganizationAccessGate.tsx b/studio-frontend/src-app/app/layout/OrganizationAccessGate.tsx index 75740642..45461dbc 100644 --- a/studio-frontend/src-app/app/layout/OrganizationAccessGate.tsx +++ b/studio-frontend/src-app/app/layout/OrganizationAccessGate.tsx @@ -5,34 +5,196 @@ * * This is a supported state, not an error (ADR-0011 §3). Authentication * establishes who somebody is; it does not grant organization membership, so - * "signed in with nowhere to go" is a normal place to be — on a first login - * before an invitation, or after a membership is revoked. + * "signed in with nowhere to go" is a normal place to be — on a first login, or + * after a membership is revoked. * - * It deliberately shows nothing about the installation: no organization names, - * no workspaces, no member directory, no create-organization control. Naming an - * organization to somebody with no membership would leak the tenant tree to - * anybody who can authenticate, which is the whole failure ADR-0011 exists to - * prevent. + * It is also a state somebody can leave without an administrator, which is what + * ADR-0018 §2 changed: they create their own organization and own it, or they + * accept an invitation already addressed to them. Both are offered here, and + * neither reveals anything about the installation. The screen still names no + * organization it has not been asked about, lists no workspaces and no members: + * naming an organization to somebody with no membership would leak the tenant + * tree to anybody who can authenticate, which is the failure ADR-0011 exists to + * prevent. An invitation is the exception the invitation itself creates — its + * organization was already disclosed to this person when it was sent. + * + * Where creation is off — an installation inside one company, whose people are + * joined to the organization it already has — the control is absent rather than + * present-and-refusing: the capability is read before it is offered. */ import React from 'react'; -import { useAppSelector } from '@gears-frontx/react'; +import { apiRegistry, eventBus, useAppSelector } from '@gears-frontx/react'; +import { + IdentityApiService, + OrganizationsApiService, + type Invitation, +} from '@/app/api'; import { APP_CONTEXT_SLICE_KEY, type AppContextState } from '@/app/slices/appContextSlice'; -export const OrganizationAccessGate: React.FC = () => ( -
-
-

You do not have access to an organization yet

-

- Ask a Studio administrator or an organization owner for an invitation. Once you have one, - your organizations appear in the top bar. -

+/** What the screen is waiting on, so it never shows two things at once. */ +type Busy = 'idle' | 'loading' | 'creating' | 'accepting'; + +export const OrganizationAccessGate: React.FC = () => { + const [canCreate, setCanCreate] = React.useState(false); + const [invitations, setInvitations] = React.useState([]); + const [name, setName] = React.useState(''); + const [busy, setBusy] = React.useState('loading'); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let live = true; + const load = async () => { + const [capabilities, waiting] = await Promise.all([ + apiRegistry.has(OrganizationsApiService) + ? apiRegistry + .getService(OrganizationsApiService) + .capabilities.fetch() + .catch(() => null) + : null, + apiRegistry.has(IdentityApiService) + ? apiRegistry + .getService(IdentityApiService) + .myInvitations.fetch() + .catch(() => null) + : null, + ]); + if (!live) return; + setCanCreate(capabilities?.self_service ?? false); + setInvitations(waiting?.items ?? []); + setBusy('idle'); + }; + void load(); + return () => { + live = false; + }; + }, []); + + /** + * Both paths end the same way: ask the shell to resolve its context again. + * Whatever just became true is true on the server, and re-reading it is how + * this screen goes away — guessing what changed would be a second source of + * truth for the thing this screen exists to reflect. + */ + const reloadContext = () => eventBus.emit('app/context/fetch'); + + const create = async (event: React.FormEvent) => { + event.preventDefault(); + if (!name.trim() || busy !== 'idle') return; + setBusy('creating'); + setError(null); + try { + await apiRegistry.getService(OrganizationsApiService).create.fetch({ name: name.trim() }); + reloadContext(); + } catch (failure) { + setError(messageOf(failure)); + setBusy('idle'); + } + }; + + const accept = async (invitation: Invitation) => { + if (busy !== 'idle') return; + setBusy('accepting'); + setError(null); + try { + await apiRegistry + .getService(IdentityApiService) + .acceptInvitation.fetch({ invitation_id: invitation.id }); + reloadContext(); + } catch (failure) { + setError(messageOf(failure)); + setBusy('idle'); + } + }; + + return ( +
+
+

You are not in an organization yet

+ + {invitations.length > 0 && ( +
+

Invitations waiting for you

+
    + {invitations.map((invitation) => ( +
  • + + Join as {invitation.role} + {invitation.email} + + +
  • + ))} +
+
+ )} + + {canCreate ? ( +
+ +

+ You will own it, and can invite others once it exists. +

+
+ setName(event.target.value)} + disabled={busy === 'creating'} + /> + +
+
+ ) : ( + busy !== 'loading' && + invitations.length === 0 && ( +

+ Ask a Studio administrator or an organization owner for an invitation. Once you have + one, your organizations appear in the top bar. +

+ ) + )} + + {error !== null &&

{error}

} +
-
-); + ); +}; OrganizationAccessGate.displayName = 'OrganizationAccessGate'; +/** + * What to show the person when a write is refused. + * + * The backend's refusals here are written for them — "you are its only owner", + * "that invitation has expired" — so the message is passed through rather than + * replaced with something generic that says less. + */ +function messageOf(failure: unknown): string { + if (failure instanceof Error && failure.message !== '') return failure.message; + return 'That did not work. Try again in a moment.'; +} + /** * Whether the shell should show the gate instead of the mounted screen. * diff --git a/studio-frontend/src-app/app/main.tsx b/studio-frontend/src-app/app/main.tsx index 377d2f63..2013957e 100644 --- a/studio-frontend/src-app/app/main.tsx +++ b/studio-frontend/src-app/app/main.tsx @@ -3,7 +3,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { FrontXProvider, apiRegistry, createFrontXApp, registerSlice, MfeHandlerMF, gtsPlugin, FRONTX_MFE_ENTRY_MF, themeSchema, languageSchema, extensionScreenSchema, setMenuCollapsed, type JSONSchema } from '@gears-frontx/react'; import { Toaster } from '@/app/components/ui/sonner'; -import { AccountsApiService, IdentityApiService, StudioEventsApiService } from '@/app/api'; +import { AccountsApiService, IdentityApiService, OrganizationsApiService, StudioEventsApiService } from '@/app/api'; import './globals.css'; // Global styles with CSS variables import '@/app/events/bootstrapEvents'; // Register app-level events (type augmentation) import { registerBootstrapEffects } from '@/app/effects/bootstrapEffects'; // Register app-level effects @@ -64,6 +64,7 @@ gtsPlugin.registerSchema(sharedPropertySessionProfileSchemaJson as JSONSchema); // Register accounts service (application-level service for user info) apiRegistry.register(AccountsApiService); apiRegistry.register(IdentityApiService); +apiRegistry.register(OrganizationsApiService); // The backend's push channel: studio-tasks announces every background run on // it, so a view is told instead of polling. Registered on the shell so every // MFE shares one stream.