From 732c2dffe781dd81aaa779e20c1ffadfcd9246b5 Mon Sep 17 00:00:00 2001 From: ANDREI KUCHMA Date: Fri, 11 Sep 2026 18:28:13 +0800 Subject: [PATCH 1/3] feat(invitations): an owner invites, and only the invited person can accept (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(organizations): a person creates an organization and owns it The first of ADR-0018's follow-ups. 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. So one server-side operation writes all three things an owner is made of — the tenant, the membership that is the authority for organization access (ADR-0011 §2), and the owner grant the Studio PDP evaluates. There is no transaction across Postgres and account-management, so it is ordered and resumable instead: membership before grant, because an organization its creator can see but not administer is a better failure than one they cannot see at all, and a failure names the organization it created so the same call finishes it. Each write is idempotent, so resuming is safe however far the first attempt got. `membership.source` gains its third value, `creation`, beside `assignment` and `manual` — ADR-0018 §2 wants an owner's member list to show how each person got in, and creating the place is not the same as being put in it. The access-config document — the thing the PDP reads — had three private copies of its shape in this assembly: the directory that writes the owner grant, the identity gear that asks who owns an organization, and the PDP that evaluates it. Writing a fourth for this gear would have been three chances of drift becoming four, so the shape, the read and the write are now one module. The PDP keeps its own deserialization: it also carries roles and privilege expansion and sits on the authorization path, where a refactor is not free. VERIFIED ON A STAND, AND IT FOUND THE BLOCKER A caller whose token names the platform root creates an organization end to end: tenant, membership (owner/creation), grant; repeating with organization_id returns the same organization and leaves the membership count at one. An ordinary person cannot. Same code, same request, and the grant write fails with "tenant not found", because the PDP clamps every request to the subtree of `subject_tenant_id` — the home tenant of the *login* — and an organization just created under the platform root is outside it. The tenant is created and the membership is recorded; the creator then cannot administer what they own. That is the same root cause the whole ADR-0018 line is about, one level deeper than expected: not only administrative rights but the tenant clamp itself is derived from the token rather than from membership. Self-service creation therefore cannot be released to ordinary people until the clamp comes from a person's memberships, which reorders ADR-0018's follow-ups — the clamp has to move first. The operation here is correct and complete; what it needs is a policy that can see what it wrote. Signed-off-by: Andrej Kuchma * feat(authz): the tenant clamp is what a person may reach, not what their token says Building the create-organization operation turned up the deeper half of ADR-0018: the PDP clamps every request to the subtree of `subject_tenant_id`, the home tenant of the *login*. So a person who created an organization could not then read or administer it — the tenant and the membership were written and the owner grant failed with "tenant not found", because the thing they had just created sat outside their clamp. The clamp is now the union of that tenant and the organizations the person is a member of. Constraints in a response are OR-ed and predicates inside one are AND-ed, so "any of these tenants" needs no new platform concept: the flat arm takes a list, and `InTenantSubtree` carries a single root, so the subtree arms are one per tenant. The token's tenant stays in the union on purpose. This change can then only widen, so nothing that works today stops working — including service accounts, which have a tenant and no memberships. Removing that arm is a separate step, after the things still reading it are gone. The PDP is asked on every request, so the organization list is cached. The first version of that cache was wrong, and the stand found it: creating an organization writes the membership and then the owner grant, and the grant write was authorized against a clamp that had already cached "this person belongs to nothing" — so the creator could not finish creating their own organization until the entry expired. Memberships now carry a generation that every write moves, and a cached answer is good only while that generation holds. The age limit stays as a backstop. A failed membership read is not a denial: the caller keeps the reach they had before memberships were consulted. Failing closed there would make a database hiccup indistinguishable from a revoked membership. VERIFIED ON A STAND An ordinary person — token tenant far from the platform root — creates an organization on the first attempt, reads it back, and holds two of them. Another person's organization answers 404 to them while their own answers 200, so the clamp widened to exactly their memberships and no further. Signed-off-by: Andrej Kuchma * feat(invitations): an owner invites, and only the invited person can accept Third in ADR-0018's order, and the thing that made the no-membership screen honest: it told people to ask an administrator, and there was no mechanism behind that sentence. An invitation is a bearer secret that becomes a membership, and everything here follows from that. The token is 244 bits, returned once, and stored only as a SHA-256 digest — a read of the table yields nothing that works. It expires in fourteen days. It is single-use, and single use is decided by the database (`UPDATE … WHERE accepted_at IS NULL`) rather than by a check followed by a write, so two acceptances racing produce one member and one refusal. WHAT AN ACCEPTANCE IS MATCHED AGAINST Not the profile e-mail. That field is self-service (`POST /me`), so matching on it would let anybody take any invitation by typing the address it was sent to — an invitation system built on it is worse than none. ADR-0011 §6 says the match must use a verified address, and we had no source for one: the directory read Keycloak's `email` and not its `emailVerified`. So the directory now reads both and publishes `verified_email`, and the trait it lives on becomes `IdpDirectoryReader` — what the IdP knows about one of its subjects — rather than being named for federation alone. With no directory configured, acceptance refuses rather than falling back. The match runs against every address the person has verified across all their sign-in methods, not only the one they are holding now. One human, several logins: an invitation sent to the address on one of them is theirs whichever way they came in today. An invitation cannot carry `owner`. Ownership comes from creating an organization or from an owner handing over deliberately (ADR-0018 §6); a link somebody forwarded is neither. Refusals do not distinguish "no such token" from "not yours", because the pair would be an oracle for guessing tokens. State is decided before identity, so a caller learns "used" or "expired" about an invitation they hold and nothing at all about one they do not. `membership.source` gains its fourth and last value, `invitation`. VERIFIED ON A STAND, with a real Keycloak and two realm users — one whose address is verified, one whose is not: the impostor holds the token → refused, no verified address the invitee sees it waiting → matched on her verified address she accepts → membership, source: invitation the same token again → already used an invented token → does not exist owner as the invited role → refused the organization's list → no tokens in it revoke, then revoke again → 204, then 404 Signed-off-by: Andrej Kuchma --------- Signed-off-by: Andrej Kuchma --- studio-backend/Cargo.lock | 2 + studio-backend/Cargo.toml | 5 + studio-backend/src/access_config.rs | 192 +++++++++++ studio-backend/src/identity_directory/mod.rs | 28 +- .../src/identity_directory/service.rs | 98 +++--- studio-backend/src/main.rs | 2 + studio-backend/src/organizations/mod.rs | 99 ++++++ studio-backend/src/organizations/rest.rs | 123 +++++++ studio-backend/src/organizations/service.rs | 194 +++++++++++ studio-backend/src/studio_authz_plugin.rs | 295 +++++++++++++++-- studio-backend/src/user_profile/entity.rs | 39 +++ .../src/user_profile/invitations.rs | 307 ++++++++++++++++++ studio-backend/src/user_profile/migrations.rs | 64 +++- studio-backend/src/user_profile/mod.rs | 56 +++- studio-backend/src/user_profile/rest.rs | 258 +++++++++++++++ studio-backend/src/user_profile/service.rs | 295 ++++++++++++++--- studio-backend/src/user_profile/store.rs | 165 +++++++++- 17 files changed, 2086 insertions(+), 136 deletions(-) create mode 100644 studio-backend/src/access_config.rs create mode 100644 studio-backend/src/organizations/mod.rs create mode 100644 studio-backend/src/organizations/rest.rs create mode 100644 studio-backend/src/organizations/service.rs create mode 100644 studio-backend/src/user_profile/invitations.rs 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/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/identity_directory/mod.rs b/studio-backend/src/identity_directory/mod.rs index 8d799ef5..455dd939 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 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 6b617096..8f6573b0 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..5f088cf9 --- /dev/null +++ b/studio-backend/src/organizations/mod.rs @@ -0,0 +1,99 @@ +//! 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::warn; +use uuid::Uuid; + +use service::OrganizationService; + +/// 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>>, +} + +#[async_trait] +impl Gear for StudioOrganizationsGear { + async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> { + // Nothing to do here: everything this gear needs comes from other gears, + // and the REST phase is the first point at which they have all + // initialized. + 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()); + Ok(rest::register_routes(router, openapi, 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()?; + Some(Arc::new(OrganizationService::new( + am, + memberships, + 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..04f6a8c0 --- /dev/null +++ b/studio-backend/src/organizations/rest.rs @@ -0,0 +1,123 @@ +//! HTTP surface for creating an organization. + +use std::sync::Arc; + +use axum::{Extension, Router}; +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, +} + +#[derive(Debug)] +#[toolkit_macros::api_dto(response)] +pub struct OrganizationDto { + pub id: String, + pub name: String, +} + +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 create_organization( + Extension(ctx): Extension, + Extension(service): Extension>>, + Json(req): Json, +) -> ApiResult> { + 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()), + } +} + +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + service: Option>, +) -> Router { + 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)) +} diff --git a/studio-backend/src/organizations/service.rs b/studio-backend/src/organizations/service.rs new file mode 100644 index 00000000..5d2c5018 --- /dev/null +++ b/studio-backend/src/organizations/service.rs @@ -0,0 +1,194 @@ +//! 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; + +/// 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()) +} + +pub struct OrganizationService { + am: Arc, + memberships: Arc, + /// The tenant every organization is created under. + platform_root: Uuid, +} + +impl OrganizationService { + pub(crate) fn new( + am: Arc, + memberships: Arc, + platform_root: Uuid, + ) -> Self { + Self { + am, + memberships, + platform_root, + } + } + + /// 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) + } +} + +#[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..d26f3c00 100644 --- a/studio-backend/src/user_profile/entity.rs +++ b/studio-backend/src/user_profile/entity.rs @@ -126,3 +126,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/migrations.rs b/studio-backend/src/user_profile/migrations.rs index 28efb20b..19a8ec1f 100644 --- a/studio-backend/src/user_profile/migrations.rs +++ b/studio-backend/src/user_profile/migrations.rs @@ -19,7 +19,7 @@ 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)] } } @@ -105,3 +105,65 @@ 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(()) + } + } +} diff --git a/studio-backend/src/user_profile/mod.rs b/studio-backend/src/user_profile/mod.rs index f7f58a95..da2843b1 100644 --- a/studio-backend/src/user_profile/mod.rs +++ b/studio-backend/src/user_profile/mod.rs @@ -14,6 +14,7 @@ mod alias_policy; mod entity; +mod invitations; mod migrations; mod rest; mod service; @@ -145,6 +146,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 +165,45 @@ 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 + } +} + +/// 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>; + + /// 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 + } + + fn membership_generation(&self) -> u64 { + service::membership_generation() + } } #[toolkit::gear( @@ -208,11 +255,16 @@ 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 organizations: Arc = svc; + ctx.client_hub().register_scoped::( + ClientScope::gts_id(IDENTITY_INSTANCE_ID), + organizations, + ); } self.service @@ -260,7 +312,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(); diff --git a/studio-backend/src/user_profile/rest.rs b/studio-backend/src/user_profile/rest.rs index 9bf15384..659e81f7 100644 --- a/studio-backend/src/user_profile/rest.rs +++ b/studio-backend/src/user_profile/rest.rs @@ -180,6 +180,49 @@ 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 { + pub token: String, +} + #[derive(Debug)] #[toolkit_macros::api_dto(response)] pub struct AliasPairDto { @@ -592,6 +635,117 @@ 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)?; + match service + .accept_invitation(&user_id, &req.token, &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>>, @@ -956,6 +1110,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..90a2a865 100644 --- a/studio-backend/src/user_profile/service.rs +++ b/studio-backend/src/user_profile/service.rs @@ -9,26 +9,22 @@ //! 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::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 +34,38 @@ 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"; + +/// 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. @@ -97,6 +125,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 +160,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 +172,7 @@ 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>>, } impl IdentityService { @@ -172,7 +199,7 @@ impl IdentityService { /// 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 +208,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 @@ -568,7 +581,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? { @@ -662,6 +675,7 @@ impl IdentityService { updated_at_epoch_ms: now, }; self.store.upsert_membership(&view).await?; + memberships_changed(); Ok(view) } @@ -681,9 +695,171 @@ 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(()) + } + + /// 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() + .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, + token: &str, + verified_emails: &[String], + ) -> Result> { + let digest = invitations::digest_of(token); + let found = self.store.find_invitation_by_digest(&digest).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)) + } + /// 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 + self.store.delete_membership(user_id, org_id).await?; + memberships_changed(); + Ok(()) } /// Merge `from_user` into `into_user`: repoint every login, alias and @@ -730,6 +906,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) } } @@ -946,6 +1125,24 @@ 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_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 +1152,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 +1162,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..af21fff4 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) @@ -57,6 +59,24 @@ 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>; + 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) ───────────────────────────────────────────── @@ -115,6 +135,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> { @@ -419,4 +453,131 @@ 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_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) + } } From adbc865ff640df374f8e2ed8adc3071ac262112f Mon Sep 17 00:00:00 2001 From: ANDREI KUCHMA Date: Mon, 14 Sep 2026 10:38:37 +0800 Subject: [PATCH 2/3] docs(adr): an identity proves it is you; the person decides everything else (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0006, 0014, 0015, 0016 and 0017 each moved one consumer off the sign-in method and onto the person. Mapping the subsystem afterwards showed that was not five oversights but one missing rule, and that the rule was still being broken in a place nobody had looked: `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 — decide from it whether the caller is a platform administrator. The attribute belongs to a Keycloak user, which is a login. A person with two logins is an administrator through one of them and an ordinary member through the other. So: an identity establishes that it is you, and everything you can see and do follows from the person. Identities own facts about signing in, because an audit has to say how an action arrived; product state hangs off the person. Onboarding follows from that rule rather than from who is available to approve. A person arrives with no organization, which is normal, and creates their own — becoming its owner and inviting others. Invitations ship with creation, because an owner who cannot invite anyone has built a product for one, and because the no-membership screen currently points at an administrator with no mechanism behind them. The platform administrator resolves conflicts — last owner gone, two persons who are one human, a contested external identity, a suspended organization — and is not on anybody's first day. This supersedes ADR-0011 §3 (which forbade showing organization creation) and §4 (which reserved creation and ownership to a platform administrator). §1, §2, §6 and §7 stand. It also settles cloud versus a single-company installation without a deployment mode. The domain model is identical in both; what differs is how a person gets their first organization, so it is two provisioning settings and one UI rule derived from data — a person with one organization has nothing to switch to. In a single-company installation the first login creates a membership, which is not the same as deriving access from authentication: a row can be revoked without touching the corporate directory, and it records how it came about. That also names the replacement for the `tenant_id` attribute, which today's single-company story quietly rests on and which this ADR deletes. Signed-off-by: Andrej Kuchma --- ...oves-it-is-you-and-decides-nothing-else.md | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md 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..cd63d408 --- /dev/null +++ b/docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md @@ -0,0 +1,323 @@ +# 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 + +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. +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). +3. **Invitations** (ADR-0011 §6), shipped with it — and, like it, gated on (1). +4. **Platform administrator as a root membership**, in the three migration steps + of §3 above. +5. **`on_first_login` provisioning** for the single-company profile. +6. **Leaving**, with the credential cleanup of §6 in the same operation, and + owner-to-owner transfer so the last owner can hand over. +7. **Retire the `tenant_id` attribute** once §3 lands — the last thing keeping + ADR-0016's follow-up open. +8. **`membership.status`** for suspension (ADR-0011 §2), when membership + management ships. +9. **Enforcement** — ADR-0011 §7 is still unmet, and the membership-management + UI this ADR describes is exactly what it gates. From ed1da744d42bf04d93fcb94ed20812f957627371 Mon Sep 17 00:00:00 2001 From: ANDREI KUCHMA Date: Mon, 14 Sep 2026 10:42:59 +0800 Subject: [PATCH 3/3] feat(identity): the person decides, and everything that follows from it (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(identity): being a platform administrator is a membership, not a claim in a token Fourth in ADR-0018's order, and the last place where a right was read off the way somebody signed in. `config/oidc.yaml` maps the Keycloak user attribute `tenant_id` into `subject_tenant_id`, and three comparisons against the platform root decided from it whether a caller administers the installation. That attribute belongs to a Keycloak user — a login — so a person with two of them was an administrator through one and an ordinary member through the other. Administering the platform is now holding a membership of the platform root. One spelling of the rule, published so the identity directory and the identity gear cannot drift apart on it. Both signals are accepted while the migration runs, and that is the whole point of doing it in this order: an installation names its administrators in configuration (`platform_admins`), those memberships are seeded idempotently at every start, ADR-0016's backfill already wrote the rows for identities that carried the attribute, and only when both are true everywhere can the token reading go. Removing it before that is a lockout: a deployment whose administrators were only ever administrators by token would have none, and no way to make one. Seeding is logged rather than fatal. An installation that cannot reach its database has a larger problem than an unseeded administrator, and refusing to boot would hide it. VERIFIED ON A STAND, with nobody's token naming the root: administrator — not root in the token, seeded membership → 200 ordinary person — neither → 403 the seeded row → owner / bootstrap The directory's gate uses the same helper; on that stand it answers 503 because Keycloak admin is unconfigured there, so it is covered by construction rather than exercised. Signed-off-by: Andrej Kuchma * feat(identity): two provisioning settings, and the cloud and on-prem split stops being two products Fifth in ADR-0018's order, and the part that makes §4 real: the domain model is identical whether Studio runs as a cloud service or inside one company. What differs is only how a person comes by their first organization, and that is now two settings rather than a deployment mode with branches behind it. studio-user.config.on_first_login — join this organization when a person is first seen, as this role studio-organizations.config .self_service — may a person create an organization Cloud leaves the first unset and the second true; an installation inside one company does the opposite. Nothing else in the model, and no authorization path, is aware of which of the two it is running as. WHY AUTO-JOIN IS NOT ACCESS DERIVED FROM AUTHENTICATION The membership is written at the one moment a person begins to exist, and it is a row. It can be revoked — suspending somebody in Studio without removing them from the corporate directory, which is frequently what a company actually wants — and `membership.source` records that it came from a first login rather than from an invitation or an operator. Access that followed the token could do neither. ADR-0011 §1 stands: authentication still grants nothing by itself; the deployment's statement does, and the statement is data. The join happens on provisioning, not on every request. If it ran per request a revoked membership would come straight back, which would make revocation a lie. `on_first_login.role` may not be `owner`: a deployment does not hand ownership to everybody who signs in. A configuration that says so is refused with a log rather than obeyed. Both new settings fail soft. A join that cannot be written is logged, because a person who exists but has not joined is a person the next request can still join, while failing there would leave them unable to sign in at all. `GET /studio-organizations/v1/capabilities` reports `self_service`, so the portal's no-organization screen can offer creation where creation is possible and say "wait for an invitation" where it is not — rather than offering a control that answers 403. VERIFIED ON A STAND, by running one deployment as both: cloud phase capabilities → self_service: true; an organization is created on-prem phase capabilities → self_service: false a brand-new subject's first request → member / first_login creation → 403 SELF_SERVICE_DISABLED, for the administrator too membership revoked → no memberships, and /me still answers 200 Signed-off-by: Andrej Kuchma * feat(identity): leaving is yours to do, and your credentials go with you Membership could be granted and it could be taken away, but nobody could end their own. The one route that removed a membership was gated on being the organization's owner, so the person who wanted to walk out had to ask the people they were walking out on — and the last owner could remove themselves, leaving an organization with members and nobody able to administer it. Two things change. Leaving is self-service. `DELETE /studio-user/v1/me/memberships/{org_id}` asks nobody's permission, because leaving is nobody's permission to give. An organization always has an owner, and one rule now holds that up wherever a membership changes — leaving, being removed, being demoted. `user_profile::leaving` states it with no IO and eight tests: an ordinary member always may; one of two owners may; the only owner is told to appoint another first; and the only person in an organization is told that leaving it would mean deleting it, which is a separate, deliberate act. Demoting the only owner is refused in the same words as removing them, so the invariant cannot be enforced on one route and walked around on another. What goes with the leaver is their personal connections. A personal connection holds their own credential, and under ADR-0012 the record is also their proof of controlling that external account: an organization they are no longer part of must keep neither. Shared connections belong to the organization and stay, and so do documents, projects and authorship — history is not rewritten because somebody left. `created_by` is resolved to a person before it is compared, so a connection created under one of their logins goes with them when they leave under another (ADR-0014). `remove_membership` is gone rather than left beside the new path: a second door that skips the gate and leaves the credentials behind is exactly what this is meant to close. Verified on an isolated stand (own Postgres, static tokens): the only person is refused; a member leaves; the only owner is refused until somebody else is made owner; demoting the only owner is refused; a non-member gets a 404; and on leaving, the leaver's personal connection disappears while the shared one and another person's personal one stay — including a connection created under a login that had since been merged into the leaver. Signed-off-by: Andrej Kuchma * feat(identity): an administrator is a person we appointed, not a tenant in a token ADR-0018 §3's third step. Platform administrator stopped meaning "this token's tenant is the root" and started meaning "this person holds a membership of the platform root"; both signals were accepted while the migration ran, and this removes the first. Two gates read the token — the identity gear's and the directory's — and with those readings gone the compiler found what was left: the platform root constant in each file had no other user. That is the whole point of the step. `tenant_id` on a Keycloak identity no longer decides anything about organizations, which is what ADR-0016's follow-up was waiting for. Removing a signal can lock people out, so every profile now names its administrator instead of relying on one: - `dev` and `postgres` name the static-token admin they already ship; - `docker` and `oidc` name the realm's `admin` user, and `realm-studio.json` pins its id so a profile *can* name it — Keycloak generates one per import otherwise, and an id that changes with the volume is no id to configure against; - `k8s` stays empty, because a deployment's administrators are its own, and says so. An installation that names nobody now has no administrator at all, and the gear says so at boot rather than leaving it to be discovered at the first 403. Signed-off-by: Andrej Kuchma * feat(organizations): an organization can be disposed of, and it takes its credentials with it The last person in an organization is told that leaving it would mean deleting it, and that deletion is a separate, deliberate act (ADR-0018 §6.4). Until now there was nothing on the other side of that sentence. `DELETE /studio-organizations/v1/organizations/{org_id}` is the other end of creating one, and it undoes creation in reverse. Every membership ends and every member's personal connections go with it, then the tenant is removed — that order, because the catalogue holding those connections lives inside the tenant's metadata, so removing the tenant first would leave every member's token behind in credstore with nothing left to read it through. Membership is also the authority for access (ADR-0011 §2), which makes it the first thing that must stop being true. Its owner may, and so may a platform administrator — the second for the break-glass case of §6.5, where the owner is gone and somebody still has to dispose of what they left. Two refusals are deliberate. A tenant that is not an organization is not deletable through this route, so a workspace or a project cannot be removed by naming it one. And account-management refuses a tenant that still has children: an organization with work in it is not disposed of by answering a prompt, and that refusal is passed through as the caller's to act on. Emptying the organization goes through a new `MembershipEvictor`, kept apart from `AssignmentRecorder` because it is the opposite act with the opposite risk. It does not consult the last-owner rule, on purpose: that rule keeps an organization administrable, and an organization being deleted has nothing left to administer — enforcing it there would make it the reason an organization can never be disposed of. Signed-off-by: Andrej Kuchma * feat(identity): a membership can be suspended, and a suspended one grants nothing ADR-0011 §2 described a membership status and the table never had one, so the only way to stop somebody's access was to delete their membership — which is leaving, a different act with a different result. An owner who wanted to suspend somebody had to remove them and remember to put them back. `identity_membership` gains `status`, `active` or `suspended`, defaulting to active for every row that already exists — those were written when active was the only state there was, so that is what they mean. Suspension is the same edit to the same row as a role change, so it arrives on the same request (`PUT .../memberships/{org_id}` takes `status`) and passes the same rule. That rule is now stated about the result rather than about the change: here is the room as it would be afterwards, is somebody in it still able to administer it? Leaving, removal, demotion and suspension all ask it, and a suspended owner is not an owner who can act — two owners on paper with one suspended is one owner, and the active one is refused if they try to leave. What a suspended membership does not do is grant. `organizations_of` — the answer the PDP clamps on and the one that decides administrative rights — returns active memberships only, so a suspended person's organization disappears from their reach while the row keeps saying where they belong and what they would come back to. Signed-off-by: Andrej Kuchma * feat(portal): a person with no organization can create one or accept an invitation The screen that says "you are not in an organization yet" said only that, and pointed at an administrator. ADR-0018 §2 removed the administrator from that path months of backend work ago; the portal never caught up, so self-service existed and nobody could reach it. Now the gate offers both ways out, and offers what the installation actually allows: it reads `GET /studio-organizations/v1/capabilities` before showing the create control, so an installation inside one company — where people are joined to the organization that already exists — shows waiting rather than a button that answers 403. Invitations waiting for this person are listed and can be accepted in place. That needed one backend change: the acceptance took a token, and the token is deliberately never stored, so the portal had nothing to send. It now takes a token *or* an invitation id, and the id is no weaker — that listing is built by matching invitations to addresses the person has proven, which is the same check the token path makes. What the token still adds is a way in for somebody the listing cannot reach: an address their provider vouches for but they have not signed in with yet. The screen still names nothing it was not asked about: no organization list, no workspaces, no member directory. An invitation names its own organization, and that was disclosed to this person when it was sent. Five tests pin what is offered when, that creating and accepting both end by asking the shell to resolve its context again rather than guessing what changed, and that a refusal written for the person reaches them intact. Signed-off-by: Andrej Kuchma * docs(adr): say where ADR-0018's follow-ups got to Eight of the nine shipped, and several meant more than they looked like from the list: the last-owner rule turned out to belong in one place rather than on the leaving route; retiring the token reading turned out to be provable by what the compiler then found unused; accepting an invitation from the portal turned out to need an id, because the token is deliberately never stored. The ninth is open on purpose, and the note says why: enforcement is the policy half, turning it on can deny requests that work today, and it deserves its own ADR rather than a paragraph in this one. A closing section names it and the one other thing left — the clamp still admits the platform root from a token, which is a question about the platform's subject model rather than about Studio's. Signed-off-by: Andrej Kuchma --------- Signed-off-by: Andrej Kuchma --- docker/keycloak/realm-studio.json | 2 + ...oves-it-is-you-and-decides-nothing-else.md | 74 ++++- keycloak/realm-studio.json | 1 + studio-backend/config/dev.yaml | 11 + studio-backend/config/docker.yaml | 15 + studio-backend/config/k8s.yaml | 10 + studio-backend/config/oidc.yaml | 15 + studio-backend/config/postgres.yaml | 11 + studio-backend/src/connectors/service.rs | 48 +++ studio-backend/src/identity_directory/mod.rs | 16 +- studio-backend/src/identity_directory/rest.rs | 48 ++- studio-backend/src/organizations/mod.rs | 76 ++++- studio-backend/src/organizations/rest.rs | 121 ++++++- studio-backend/src/organizations/service.rs | 80 ++++- studio-backend/src/user_profile/entity.rs | 5 + studio-backend/src/user_profile/leaving.rs | 263 ++++++++++++++++ studio-backend/src/user_profile/migrations.rs | 51 ++- studio-backend/src/user_profile/mod.rs | 146 +++++++++ studio-backend/src/user_profile/rest.rs | 236 ++++++++++++-- studio-backend/src/user_profile/service.rs | 297 +++++++++++++++++- studio-backend/src/user_profile/store.rs | 42 +++ .../src-app/app/api/IdentityApiService.ts | 24 +- .../app/api/OrganizationsApiService.ts | 60 ++++ studio-frontend/src-app/app/api/index.ts | 10 +- studio-frontend/src-app/app/api/mocks.ts | 30 +- studio-frontend/src-app/app/api/types.ts | 44 +++ .../layout/OrganizationAccessGate.test.tsx | 116 +++++++ .../app/layout/OrganizationAccessGate.tsx | 198 ++++++++++-- studio-frontend/src-app/app/main.tsx | 3 +- 29 files changed, 1963 insertions(+), 90 deletions(-) create mode 100644 studio-backend/src/user_profile/leaving.rs create mode 100644 studio-frontend/src-app/app/api/OrganizationsApiService.ts create mode 100644 studio-frontend/src-app/app/layout/OrganizationAccessGate.test.tsx 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 index cd63d408..d81ac2d8 100644 --- 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 @@ -298,6 +298,11 @@ piece of work, gated with the rest of membership management by §7. ## 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 @@ -305,19 +310,72 @@ piece of work, gated with the rest of membership management by §7. 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. + 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). + 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. -5. **`on_first_login` provisioning** for the single-company profile. + 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. + 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. + 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. + 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. + 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/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/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 455dd939..c30ade21 100644 --- a/studio-backend/src/identity_directory/mod.rs +++ b/studio-backend/src/identity_directory/mod.rs @@ -147,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/organizations/mod.rs b/studio-backend/src/organizations/mod.rs index 5f088cf9..57820186 100644 --- a/studio-backend/src/organizations/mod.rs +++ b/studio-backend/src/organizations/mod.rs @@ -23,11 +23,41 @@ use toolkit::api::OpenApiRegistry; use toolkit::client_hub::ClientScope; use toolkit::contracts::RestApiCapability; use toolkit::{Gear, GearCtx}; -use tracing::warn; +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. @@ -41,14 +71,22 @@ const PLATFORM_ROOT_TENANT_ID: Uuid = Uuid::from_u128(1); #[derive(Default)] pub struct StudioOrganizationsGear { service: OnceLock>>, + self_service: OnceLock, } #[async_trait] impl Gear for StudioOrganizationsGear { - async fn init(&self, _ctx: &GearCtx) -> anyhow::Result<()> { - // Nothing to do here: everything this gear needs comes from other gears, - // and the REST phase is the first point at which they have all - // initialized. + 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(()) } } @@ -63,7 +101,13 @@ impl RestApiCapability for StudioOrganizationsGear { ) -> anyhow::Result { let service = build_service(ctx); let _ = self.service.set(service.clone()); - Ok(rest::register_routes(router, openapi, service)) + let self_service = self.self_service.get().copied().unwrap_or(true); + Ok(rest::register_routes( + router, + openapi, + service, + rest::SelfService(self_service), + )) } } @@ -91,9 +135,29 @@ fn build_service(ctx: &GearCtx) -> Option> { ); }) .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 index 04f6a8c0..f8661340 100644 --- a/studio-backend/src/organizations/rest.rs +++ b/studio-backend/src/organizations/rest.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use axum::{Extension, Router}; +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}; @@ -34,6 +34,19 @@ pub struct CreateOrganizationRequest { 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 { @@ -41,6 +54,15 @@ pub struct OrganizationDto { 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() @@ -52,11 +74,25 @@ fn configured(service: Option>) -> ApiResult, +) -> 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, @@ -93,11 +129,93 @@ async fn create_organization( } } +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") @@ -120,4 +238,5 @@ pub fn register_routes( .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 index 5d2c5018..568e40ec 100644 --- a/studio-backend/src/organizations/service.rs +++ b/studio-backend/src/organizations/service.rs @@ -30,7 +30,7 @@ use toolkit_security::SecurityContext; use uuid::Uuid; use crate::access_config; -use crate::user_profile::AssignmentRecorder; +use crate::user_profile::{AssignmentRecorder, MembershipEvictor, OrganizationReader}; /// The tenant type an organization has. /// @@ -79,9 +79,18 @@ pub fn clean_name(raw: &str) -> Result { 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, } @@ -90,15 +99,41 @@ 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 @@ -157,6 +192,49 @@ impl OrganizationService { 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)] diff --git a/studio-backend/src/user_profile/entity.rs b/studio-backend/src/user_profile/entity.rs index d26f3c00..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, 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 19a8ec1f..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), Box::new(m0002::Migration)] + vec![ + Box::new(m0001::Migration), + Box::new(m0002::Migration), + Box::new(m0003::Migration), + ] } } @@ -167,3 +171,48 @@ CREATE INDEX IF NOT EXISTS idx_identity_invitation_email } } } + +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 da2843b1..5502b630 100644 --- a/studio-backend/src/user_profile/mod.rs +++ b/studio-backend/src/user_profile/mod.rs @@ -15,6 +15,7 @@ mod alias_policy; mod entity; mod invitations; +mod leaving; mod migrations; mod rest; mod service; @@ -34,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. @@ -171,6 +227,39 @@ impl AssignmentRecorder for IdentityService { } } +/// 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 @@ -186,6 +275,12 @@ pub trait OrganizationReader: Send + Sync + 'static { /// 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 @@ -201,6 +296,10 @@ impl OrganizationReader for IdentityService { 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() } @@ -260,6 +359,11 @@ impl Gear for StudioUserGear { 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), @@ -323,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 659e81f7..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, } @@ -220,7 +230,17 @@ pub struct InvitationListDto { #[derive(Debug)] #[toolkit_macros::api_dto(request)] pub struct AcceptInvitationRequest { - pub token: String, + /// 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)] @@ -285,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, @@ -314,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, @@ -335,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() @@ -345,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() @@ -417,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>>, @@ -438,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 @@ -457,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 @@ -478,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 { @@ -564,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 @@ -732,8 +851,17 @@ async fn accept_invitation( 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, &req.token, &emails) + .accept_invitation(&user_id, &offered, &emails) .await .map_err(internal)? { @@ -751,8 +879,8 @@ async fn merge_users( 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 @@ -769,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 @@ -914,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() @@ -942,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() @@ -952,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())); diff --git a/studio-backend/src/user_profile/service.rs b/studio-backend/src/user_profile/service.rs index 90a2a865..def4ca35 100644 --- a/studio-backend/src/user_profile/service.rs +++ b/studio-backend/src/user_profile/service.rs @@ -22,6 +22,7 @@ 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::IdpDirectoryReader; @@ -43,6 +44,34 @@ const SOURCE_CREATION: &str = "creation"; /// 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 @@ -110,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, @@ -173,6 +205,9 @@ pub struct IdentityService { /// The IdP proof channel, attached in the same phase and for the same /// reason. `Some(None)` means Keycloak admin is unconfigured. 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 { @@ -182,6 +217,7 @@ impl IdentityService { am, connectors: OnceLock::new(), federated: OnceLock::new(), + first_login_join: OnceLock::new(), } } @@ -194,6 +230,11 @@ 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 @@ -284,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) } @@ -653,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")); @@ -667,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. @@ -715,6 +794,55 @@ impl IdentityService { 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 @@ -729,6 +857,10 @@ impl IdentityService { .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()) } @@ -831,11 +963,21 @@ impl IdentityService { pub async fn accept_invitation( &self, user_id: &str, - token: &str, + offered: &Offered<'_>, verified_emails: &[String], ) -> Result> { - let digest = invitations::digest_of(token); - let found = self.store.find_invitation_by_digest(&digest).await?; + 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(), @@ -855,11 +997,146 @@ impl IdentityService { 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(()) + Ok(evicted) } /// Merge `from_user` into `into_user`: repoint every login, alias and @@ -1113,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") } @@ -1128,6 +1408,9 @@ mod idp_channel_tests { 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") } diff --git a/studio-backend/src/user_profile/store.rs b/studio-backend/src/user_profile/store.rs index af21fff4..3f0baf5a 100644 --- a/studio-backend/src/user_profile/store.rs +++ b/studio-backend/src/user_profile/store.rs @@ -46,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>; @@ -67,6 +70,12 @@ pub(crate) trait IdentityStore: Send + Sync { /// 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>; @@ -107,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), @@ -284,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)), @@ -293,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, ]) @@ -324,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 @@ -481,6 +509,20 @@ impl IdentityStore for PgStore { 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 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 14d1960a..33d2fa44 100644 --- a/studio-frontend/src-app/app/api/index.ts +++ b/studio-frontend/src-app/app/api/index.ts @@ -6,12 +6,20 @@ 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'; 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 be5be029..5561f84a 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 } from '@/app/api'; +import { AccountsApiService, IdentityApiService, OrganizationsApiService } 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); // Initialize API services apiRegistry.initialize({});