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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker/keycloak/realm-studio.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"accessTokenLifespan": 3600,
"users": [
{
"id": "00000000-0000-4000-8000-000000000a11",
"username": "admin",
"enabled": true,
"email": "admin@studio.local",
Expand All @@ -26,6 +27,7 @@
}
},
{
"id": "00000000-0000-4000-8000-000000000de0",
"username": "demo",
"enabled": true,
"email": "demo@studio.local",
Expand Down
381 changes: 381 additions & 0 deletions docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions keycloak/realm-studio.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"accessTokenLifespan": 3600,
"users": [
{
"id": "00000000-0000-4000-8000-000000000a11",
"username": "admin",
"enabled": true,
"email": "admin@studio.local",
Expand Down
2 changes: 2 additions & 0 deletions studio-backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions studio-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions studio-backend/config/dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions studio-backend/config/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions studio-backend/config/k8s.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions studio-backend/config/oidc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions studio-backend/config/postgres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
192 changes: 192 additions & 0 deletions studio-backend/src/access_config.rs
Original file line number Diff line number Diff line change
@@ -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<GrantDef>,
}

#[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"));
}
}
48 changes: 48 additions & 0 deletions studio-backend/src/connectors/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
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,
Expand Down
Loading