From 83891f584db49a2f57f29fc58d3b3663a8f1d35a Mon Sep 17 00:00:00 2001 From: JeffMboya Date: Wed, 15 Jul 2026 15:04:33 +0300 Subject: [PATCH 01/14] feat: bootstrap entities and credentials from a YAML config file Provision the initial entities and their password/shared-key credentials at startup from a YAML file (ATOM_BOOTSTRAP_FILE) instead of setting one *_SECRET env var per identity or driving the API by hand. The file is loaded once after migrations and is idempotent: existing entities and credentials are never mutated, so re-running is a no-op, and it runs alongside the existing env-var bootstrap. Credential creation reuses the identity service, so hashing, password-strength validation and shared-key envelope encryption are identical to the API path. Structural validation (duplicate ids, human shared keys, multiple credentials of a kind, non-object attributes) runs before touching the database, so a malformed file aborts startup cleanly. Closes #27 --- .env.example | 6 + Cargo.lock | 20 ++ Cargo.toml | 1 + README.md | 42 ++++ bootstrap.example.yaml | 40 ++++ src/bootstrap.rs | 402 ++++++++++++++++++++++++++++++++++ src/config.rs | 5 + src/lib.rs | 1 + src/main.rs | 8 +- tests/m25_config_bootstrap.rs | 152 +++++++++++++ 10 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 bootstrap.example.yaml create mode 100644 src/bootstrap.rs create mode 100644 tests/m25_config_bootstrap.rs diff --git a/.env.example b/.env.example index 6a1a0087..32021ff3 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,12 @@ ADMIN_SECRET=12345678 ATOM_MIN_PASSWORD_CHARS=8 # ADMIN_ENTITY_ID=00000000-0000-0000-0000-000000000001 +# --- Config-file bootstrap ---------------------------------------------- +# Optional. Path to a YAML file describing entities and their credentials to +# provision at startup, applied idempotently after migrations. See +# bootstrap.example.yaml. Leave unset to rely on the env-var bootstrap above. +# ATOM_BOOTSTRAP_FILE=./bootstrap.yaml + # --- Secret encryption at rest ----------------------------------------- # Root AES-256-GCM key encrypting every recoverable secret: signing private # keys and retrievable credential secrets (shared keys). Required to create diff --git a/Cargo.lock b/Cargo.lock index d22a1a45..6b311a44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,7 @@ dependencies = [ "ring", "serde", "serde_json", + "serde_yaml", "sqlx", "thiserror 1.0.69", "time", @@ -3758,6 +3759,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4753,6 +4767,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index f3115ba5..4ca65a97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate", "macros", "derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" jsonschema = { version = "0.18", default-features = false } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/README.md b/README.md index a7fbc15a..e41ab916 100644 --- a/README.md +++ b/README.md @@ -659,6 +659,7 @@ Generic application mapping: | `ADMIN_SECRET` | *(optional)* | Seeds the admin password on first boot | | `ADMIN_ENTITY_ID` | `00000000-0000-0000-0000-000000000001` | Override seeded admin UUID | | `ATOM_SERVICE_SECRET` / `ATOM_SERVICE_ENTITY_ID` | *(optional)* / seeded service UUID | Seeds a service entity password on first boot | +| `ATOM_BOOTSTRAP_FILE` | *(optional)* | Path to a YAML file provisioning entities + credentials at startup (idempotent) | | `ATOM_MIN_PASSWORD_CHARS` | `12` | Minimum password length | | `ATOM_CORS_ALLOWED_ORIGINS` | `ATOM_PUBLIC_BASE_URL` | Comma-separated allowed CORS origins | | `ATOM_AUTH_COOKIE_SECURE` / `ATOM_AUTH_COOKIE_DOMAIN` | auto-detect HTTPS / *(unset)* | Auth cookie options for UI flows | @@ -777,6 +778,47 @@ ingress that overwrites client IP headers. If the Atom UI is also proxying requests to Atom, enable `ATOM_UI_FORWARD_CLIENT_IP_HEADERS=true` only behind an upstream proxy that sanitizes those headers. +### Bootstrapping with a config file + +Standing up a fresh deployment no longer requires driving the API by hand or +juggling one `*_SECRET` env var per identity. Point Atom at a YAML file and it +provisions the declared entities and their credentials at startup: + +```bash +ATOM_BOOTSTRAP_FILE=./bootstrap.yaml +``` + +```yaml +# bootstrap.yaml +entities: + # Attach a password to the pre-seeded admin (replaces ADMIN_SECRET). + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + credentials: + - kind: password + secret: change-me-please + + # A new service identity with a machine shared key. + - id: 11111111-1111-1111-1111-111111111111 + kind: service + name: ingest-service + credentials: + - kind: shared_key + key: replace-with-a-strong-machine-secret + description: telemetry ingest pipeline +``` + +The file is applied once, right after migrations, and is **idempotent**: each +entity is keyed on its stable UUID and each credential is created only when the +entity has no active credential of that kind, so re-running against an +already-provisioned database is a no-op and never clobbers runtime changes. It +runs alongside the env-var bootstrap above, not instead of it. `shared_key` +credentials are only valid for machine (non-human) entities and require an +explicit `key`. Secrets are written in plaintext just like `ADMIN_SECRET`, so +treat the file as a secret (restrict its mode, keep it out of version control). +See [`bootstrap.example.yaml`](bootstrap.example.yaml) for a fuller example. + --- ## Authentication diff --git a/bootstrap.example.yaml b/bootstrap.example.yaml new file mode 100644 index 00000000..32285034 --- /dev/null +++ b/bootstrap.example.yaml @@ -0,0 +1,40 @@ +# Atom bootstrap file (example). +# +# Point Atom at this file with ATOM_BOOTSTRAP_FILE=/path/to/bootstrap.yaml. +# It is applied once at startup, right after migrations, and is idempotent: +# entities/credentials that already exist are left untouched, so it is safe to +# leave configured across restarts. +# +# Secrets are declared inline (just like ADMIN_SECRET) — protect this file: +# mount it as a secret, keep it out of version control, restrict its mode. + +entities: + # Attach a password to the pre-seeded platform admin (well-known seed UUID). + # This replaces setting ADMIN_SECRET. + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + credentials: + - kind: password + secret: change-me-please + + # A brand-new service identity with a machine shared key. + - id: 11111111-1111-1111-1111-111111111111 + kind: service + name: ingest-service + alias: ingest-service + attributes: + system: true + purpose: telemetry-ingest + credentials: + - kind: shared_key + key: replace-with-a-strong-machine-secret + description: telemetry ingest pipeline + + # A device identity with a shared key. + - id: 22222222-2222-2222-2222-222222222222 + kind: device + name: gateway-01 + credentials: + - kind: shared_key + key: replace-with-a-strong-device-secret diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 00000000..d0becc49 --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,402 @@ +//! Declarative startup bootstrap from a YAML configuration file. +//! +//! Standing up a fresh Atom deployment previously meant either setting a handful +//! of `*_SECRET` env vars or driving the API by hand to create the initial +//! entities and their credentials. Neither is friendly for repeatable, reviewable +//! platform management. +//! +//! This module lets an operator describe the desired baseline in a single YAML +//! file (pointed to by `ATOM_BOOTSTRAP_FILE`). The file is loaded once at +//! startup, right after migrations, and applied **idempotently**: re-running it +//! against an already-provisioned database is a no-op. Existing entities and +//! credentials are never mutated or clobbered — bootstrap only fills in what is +//! missing, keyed on the stable UUIDs declared in the file. +//! +//! ## Example +//! +//! ```yaml +//! entities: +//! - id: 00000000-0000-0000-0000-000000000001 +//! kind: human +//! name: admin +//! credentials: +//! - kind: password +//! secret: change-me-please +//! - id: 11111111-1111-1111-1111-111111111111 +//! kind: service +//! name: ingest-service +//! credentials: +//! - kind: shared_key +//! key: super-secret-machine-key +//! description: ingest pipeline +//! ``` + +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Deserialize; +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::config::SigningKeyConfig; +use crate::identity; +use crate::models::alias::validate_alias_opt; +use crate::models::enums::{CredentialKind, EntityKind, EntityStatus}; +use crate::models::token::CreateSharedKey; + +/// Root of the bootstrap document. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapConfig { + #[serde(default)] + pub entities: Vec, +} + +/// A single entity to ensure exists, together with its credentials. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapEntity { + /// Stable UUID. Required so re-runs are deterministic and idempotent — it is + /// the key we upsert on. Use the well-known seed UUIDs to attach credentials + /// to the pre-seeded `admin`/`example-service` entities. + pub id: Uuid, + pub kind: EntityKind, + pub name: String, + /// Optional human-friendly slug (unique per tenant). Validated with the same + /// rules as the API. + #[serde(default)] + pub alias: Option, + #[serde(default)] + pub status: EntityStatus, + /// Free-form JSON object. Defaults to `{}`. + #[serde(default)] + pub attributes: Option, + #[serde(default)] + pub credentials: Vec, +} + +/// A credential to ensure exists for an entity. The secret material is declared +/// inline, exactly like the existing `ADMIN_SECRET` env var — protect the file +/// accordingly (mount it as a secret, keep it out of version control). +// `deny_unknown_fields` is intentionally omitted: serde does not support it on +// internally tagged enums (it would reject the `kind` discriminant itself). +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BootstrapCredential { + /// A password credential. Validated against the configured strength policy. + Password { secret: String }, + /// A retrievable machine shared key. Only valid for non-human entities. The + /// key must be supplied explicitly so bootstrap stays deterministic (an + /// auto-generated key would be lost, never surfaced to the operator). + SharedKey { + key: String, + #[serde(default)] + description: Option, + }, +} + +impl BootstrapConfig { + /// Structural validation performed before touching the database, so a + /// malformed file aborts startup with a clear message instead of a partial, + /// half-applied bootstrap. + pub fn validate(&self) -> Result<()> { + let mut seen_ids = std::collections::HashSet::new(); + for entity in &self.entities { + if !seen_ids.insert(entity.id) { + bail!("duplicate bootstrap entity id {}", entity.id); + } + if entity.name.trim().is_empty() { + bail!("bootstrap entity {} has an empty name", entity.id); + } + if let Some(attrs) = &entity.attributes { + if !attrs.is_object() { + bail!( + "bootstrap entity {} attributes must be a JSON object", + entity.id + ); + } + } + + let mut passwords = 0; + let mut shared_keys = 0; + for cred in &entity.credentials { + match cred { + BootstrapCredential::Password { .. } => passwords += 1, + BootstrapCredential::SharedKey { .. } => { + shared_keys += 1; + if !CredentialKind::SharedKey.allowed_for(&entity.kind) { + bail!( + "bootstrap entity {} is a human; shared keys are only valid for machine entities", + entity.id + ); + } + } + } + } + if passwords > 1 { + bail!( + "bootstrap entity {} declares more than one password credential", + entity.id + ); + } + if shared_keys > 1 { + bail!( + "bootstrap entity {} declares more than one shared_key credential", + entity.id + ); + } + } + Ok(()) + } +} + +/// Read and parse a bootstrap file, validating its structure. +pub fn load(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("failed to read bootstrap file {}", path.display()))?; + parse(&contents).with_context(|| format!("invalid bootstrap file {}", path.display())) +} + +fn parse(contents: &str) -> Result { + let cfg: BootstrapConfig = serde_yaml::from_str(contents).context("failed to parse YAML")?; + cfg.validate()?; + Ok(cfg) +} + +/// Apply the bootstrap config against the database. Idempotent. +pub async fn apply( + pool: &PgPool, + signing_keys: &SigningKeyConfig, + cfg: &BootstrapConfig, +) -> Result<()> { + for entity in &cfg.entities { + ensure_entity(pool, entity).await?; + for cred in &entity.credentials { + ensure_credential(pool, signing_keys, entity, cred).await?; + } + } + Ok(()) +} + +/// Create the entity if its UUID is not already present. Existing rows are left +/// untouched, so a bootstrap re-run never overwrites runtime edits. +async fn ensure_entity(pool: &PgPool, entity: &BootstrapEntity) -> Result<()> { + let alias = validate_alias_opt(entity.alias.clone()) + .map_err(|e| anyhow!("bootstrap entity {}: {e}", entity.id))?; + let attributes = entity + .attributes + .clone() + .unwrap_or_else(|| serde_json::json!({})); + + let result = sqlx::query( + r#"INSERT INTO entities (id, kind, name, alias, status, attributes) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(entity.id) + .bind(&entity.kind) + .bind(&entity.name) + .bind(alias) + .bind(&entity.status) + .bind(attributes) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap entity {}", entity.id))?; + + if result.rows_affected() == 0 { + tracing::info!(entity_id = %entity.id, "bootstrap: entity already present, skipped"); + } else { + tracing::info!(entity_id = %entity.id, kind = ?entity.kind, "bootstrap: entity created"); + } + Ok(()) +} + +/// Create the credential only if the entity has no active credential of that +/// kind yet. Reuses the identity service so hashing, strength validation and +/// shared-key envelope encryption stay identical to the API path. +async fn ensure_credential( + pool: &PgPool, + signing_keys: &SigningKeyConfig, + entity: &BootstrapEntity, + cred: &BootstrapCredential, +) -> Result<()> { + match cred { + BootstrapCredential::Password { secret } => { + if active_credential_exists(pool, entity.id, CredentialKind::Password).await? { + tracing::info!(entity_id = %entity.id, "bootstrap: password already present, skipped"); + return Ok(()); + } + identity::service::create_password(pool, entity.id, secret) + .await + .map_err(|e| anyhow!("bootstrap password for entity {}: {e}", entity.id))?; + tracing::info!(entity_id = %entity.id, "bootstrap: password credential created"); + } + BootstrapCredential::SharedKey { key, description } => { + if active_credential_exists(pool, entity.id, CredentialKind::SharedKey).await? { + tracing::info!(entity_id = %entity.id, "bootstrap: shared key already present, skipped"); + return Ok(()); + } + identity::service::create_shared_key( + pool, + signing_keys, + entity.id, + CreateSharedKey { + expires_at: None, + description: description.clone(), + key: Some(key.clone()), + }, + ) + .await + .map_err(|e| anyhow!("bootstrap shared key for entity {}: {e}", entity.id))?; + tracing::info!(entity_id = %entity.id, "bootstrap: shared key credential created"); + } + } + Ok(()) +} + +async fn active_credential_exists( + pool: &PgPool, + entity_id: Uuid, + kind: CredentialKind, +) -> Result { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM credentials WHERE entity_id = $1 AND kind = $2 AND status = 'active'", + ) + .bind(entity_id) + .bind(kind) + .fetch_one(pool) + .await + .context("failed to check existing bootstrap credential")?; + Ok(count > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_entities_with_credentials() { + let yaml = r#" +entities: + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + attributes: + role: admin + credentials: + - kind: password + secret: change-me-please + - id: 11111111-1111-1111-1111-111111111111 + kind: service + name: ingest + alias: ingest-svc + credentials: + - kind: shared_key + key: super-secret-key + description: ingest pipeline +"#; + let cfg = parse(yaml).expect("parse"); + assert_eq!(cfg.entities.len(), 2); + + let admin = &cfg.entities[0]; + assert_eq!(admin.kind, EntityKind::Human); + assert_eq!(admin.name, "admin"); + assert_eq!(admin.status, EntityStatus::Active); + assert_eq!( + admin.credentials, + vec![BootstrapCredential::Password { + secret: "change-me-please".to_string() + }] + ); + + let svc = &cfg.entities[1]; + assert_eq!(svc.kind, EntityKind::Service); + assert_eq!(svc.alias.as_deref(), Some("ingest-svc")); + assert_eq!( + svc.credentials, + vec![BootstrapCredential::SharedKey { + key: "super-secret-key".to_string(), + description: Some("ingest pipeline".to_string()), + }] + ); + } + + #[test] + fn empty_document_is_valid_and_empty() { + let cfg = parse("entities: []").expect("parse"); + assert!(cfg.entities.is_empty()); + } + + #[test] + fn unknown_fields_are_rejected() { + let yaml = r#" +entities: + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + typo_field: oops +"#; + assert!(parse(yaml).is_err(), "unknown field should be rejected"); + } + + #[test] + fn duplicate_entity_ids_are_rejected() { + let yaml = r#" +entities: + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin-two +"#; + let err = parse(yaml).expect_err("duplicate ids"); + assert!(err.to_string().contains("duplicate bootstrap entity id")); + } + + #[test] + fn shared_key_on_human_is_rejected() { + let yaml = r#" +entities: + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + credentials: + - kind: shared_key + key: nope +"#; + let err = parse(yaml).expect_err("human shared key"); + assert!(err.to_string().contains("shared keys are only valid")); + } + + #[test] + fn multiple_passwords_per_entity_are_rejected() { + let yaml = r#" +entities: + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + credentials: + - kind: password + secret: one-secret + - kind: password + secret: two-secret +"#; + let err = parse(yaml).expect_err("two passwords"); + assert!(err.to_string().contains("more than one password")); + } + + #[test] + fn non_object_attributes_are_rejected() { + let yaml = r#" +entities: + - id: 00000000-0000-0000-0000-000000000001 + kind: human + name: admin + attributes: "not-an-object" +"#; + let err = parse(yaml).expect_err("scalar attributes"); + assert!(err.to_string().contains("must be a JSON object")); + } +} diff --git a/src/config.rs b/src/config.rs index c8a90023..b8fde3e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,9 @@ pub struct Config { /// If set, the service entity's password credential is created on first boot. pub service_secret: Option, pub service_entity_id: Uuid, + /// Path to a YAML bootstrap file applied idempotently at startup. `None` + /// disables config-file bootstrap (env-var/API bootstrap is unaffected). + pub bootstrap_file: Option, /// Enables unauthenticated global human self-registration. pub self_registration_enabled: bool, /// Development-only: allow password login before the signup email is verified. @@ -595,6 +598,7 @@ impl Config { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(SERVICE_ENTITY_ID), + bootstrap_file: nonempty_env("ATOM_BOOTSTRAP_FILE"), self_registration_enabled: env_bool_default("ATOM_SELF_REGISTRATION_ENABLED", true), dev_allow_unverified_email_login: env_bool("ATOM_ALLOW_UNVERIFIED_EMAIL_LOGIN"), cors_allowed_origins: parse_cors_allowed_origins(&public_base_url), @@ -676,6 +680,7 @@ impl Config { admin_secret: None, service_secret: None, service_entity_id: SERVICE_ENTITY_ID, + bootstrap_file: None, self_registration_enabled: false, dev_allow_unverified_email_login: false, public_base_url: "http://localhost:8080".into(), diff --git a/src/lib.rs b/src/lib.rs index d681feb8..b0d1065d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod api_endpoints; pub mod audit; pub mod auth; pub mod authz; +pub mod bootstrap; pub mod broker_auth; pub mod build_info; pub mod certs; diff --git a/src/main.rs b/src/main.rs index 06916f4b..1e31e2a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Context; use atom::{ - audit, certs, config, db, events, grpc, identity, keys, metrics, purge, routes, + audit, bootstrap, certs, config, db, events, grpc, identity, keys, metrics, purge, routes, state::{self, GrpcRuntimeStatus}, }; use tracing_subscriber::EnvFilter; @@ -31,6 +31,12 @@ async fn main() -> anyhow::Result<()> { bootstrap_password_credentials(&pool, cfg.service_entity_id, secret, "service").await?; } + if let Some(ref path) = cfg.bootstrap_file { + let bootstrap_cfg = bootstrap::load(std::path::Path::new(path))?; + bootstrap::apply(&pool, &cfg.signing_keys, &bootstrap_cfg).await?; + tracing::info!("bootstrap file applied: {path}"); + } + keys::bootstrap_if_needed(&pool, &cfg.signing_keys).await?; let certificate_issuer = certs::service::load_file_issuer_if_enabled(&cfg)?; let active_keys = keys::load_active_keys(&pool, &cfg.signing_keys).await?; diff --git a/tests/m25_config_bootstrap.rs b/tests/m25_config_bootstrap.rs new file mode 100644 index 00000000..d61e479e --- /dev/null +++ b/tests/m25_config_bootstrap.rs @@ -0,0 +1,152 @@ +//! Config-file bootstrap integration tests (issue #27). +//! +//! Run with: +//! ```bash +//! DATABASE_URL=postgres://... cargo test --test m25_config_bootstrap -- --ignored +//! ``` + +mod common; + +use atom::bootstrap::{apply, BootstrapConfig, BootstrapCredential, BootstrapEntity}; +use atom::config::Config; +use atom::models::enums::{EntityKind, EntityStatus}; +use common::pool; +use uuid::Uuid; + +async fn count_active_credentials(pool: &sqlx::PgPool, entity_id: Uuid, kind: &str) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM credentials WHERE entity_id = $1 AND kind = $2 AND status = 'active'", + ) + .bind(entity_id) + .bind(kind) + .fetch_one(pool) + .await + .expect("count credentials") +} + +fn sample_config(human: Uuid, service: Uuid) -> BootstrapConfig { + BootstrapConfig { + entities: vec![ + BootstrapEntity { + id: human, + kind: EntityKind::Human, + name: format!("bootstrap-human-{human}"), + alias: None, + status: EntityStatus::Active, + attributes: Some(serde_json::json!({ "system": true })), + credentials: vec![BootstrapCredential::Password { + secret: "bootstrap-pw-123456".to_string(), + }], + }, + BootstrapEntity { + id: service, + kind: EntityKind::Service, + name: format!("bootstrap-service-{service}"), + alias: None, + status: EntityStatus::Active, + attributes: None, + credentials: vec![BootstrapCredential::SharedKey { + key: "bootstrap-machine-secret".to_string(), + description: Some("integration test".to_string()), + }], + }, + ], + } +} + +#[tokio::test] +#[ignore] +async fn bootstrap_creates_entities_and_credentials() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let human = Uuid::new_v4(); + let service = Uuid::new_v4(); + let cfg = sample_config(human, service); + + apply(&p, &signing_keys, &cfg) + .await + .expect("apply bootstrap"); + + let human_kind: String = sqlx::query_scalar("SELECT kind FROM entities WHERE id = $1") + .bind(human) + .fetch_one(&p) + .await + .expect("human entity exists"); + assert_eq!(human_kind, "human"); + + let service_kind: String = sqlx::query_scalar("SELECT kind FROM entities WHERE id = $1") + .bind(service) + .fetch_one(&p) + .await + .expect("service entity exists"); + assert_eq!(service_kind, "service"); + + assert_eq!(count_active_credentials(&p, human, "password").await, 1); + assert_eq!(count_active_credentials(&p, service, "shared_key").await, 1); +} + +#[tokio::test] +#[ignore] +async fn bootstrap_is_idempotent() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let human = Uuid::new_v4(); + let service = Uuid::new_v4(); + let cfg = sample_config(human, service); + + // Apply twice; the second run must not create duplicate rows. + apply(&p, &signing_keys, &cfg).await.expect("first apply"); + apply(&p, &signing_keys, &cfg).await.expect("second apply"); + + let entity_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entities WHERE id = $1") + .bind(human) + .fetch_one(&p) + .await + .expect("count human"); + assert_eq!(entity_count, 1); + + assert_eq!(count_active_credentials(&p, human, "password").await, 1); + assert_eq!(count_active_credentials(&p, service, "shared_key").await, 1); +} + +#[tokio::test] +#[ignore] +async fn bootstrap_does_not_clobber_existing_credentials() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let human = Uuid::new_v4(); + let service = Uuid::new_v4(); + + apply(&p, &signing_keys, &sample_config(human, service)) + .await + .expect("first apply"); + + let original_hash: String = + sqlx::query_scalar("SELECT secret_hash FROM credentials WHERE entity_id = $1") + .bind(human) + .fetch_one(&p) + .await + .expect("password hash"); + + // A second run declaring a different secret for the same entity must not + // rotate the existing credential — bootstrap only fills in what is missing. + let mut changed = sample_config(human, service); + changed.entities[0].credentials = vec![BootstrapCredential::Password { + secret: "a-totally-different-secret".to_string(), + }]; + apply(&p, &signing_keys, &changed) + .await + .expect("second apply"); + + let after_hash: String = + sqlx::query_scalar("SELECT secret_hash FROM credentials WHERE entity_id = $1") + .bind(human) + .fetch_one(&p) + .await + .expect("password hash after"); + assert_eq!( + original_hash, after_hash, + "existing password must be preserved" + ); + assert_eq!(count_active_credentials(&p, human, "password").await, 1); +} From 0e73dbc72bfe92dc30f1b3fe317f4259c285eaba Mon Sep 17 00:00:00 2001 From: JeffMboya Date: Wed, 15 Jul 2026 15:31:35 +0300 Subject: [PATCH 02/14] feat: bootstrap tenants, groups, roles and policies from the config file Extend the YAML bootstrap beyond entities and credentials to the full RBAC baseline: tenants, principal groups (with members), permission blocks (with actions and scope), roles (linked to blocks), role assignments and direct policies. Entities can now also declare their owning tenant. Sections are applied in dependency order and every record is keyed on a stable UUID and inserted with ON CONFLICT DO NOTHING, so the whole graph stays idempotent and safe to re-run. Records may reference rows that already exist in the database. Structural validation (unique ids per section, scope/mode column combinations mirroring the DB constraint, object attributes) runs before any write. --- .env.example | 3 +- AGENTS.md | 7 +- README.md | 60 ++- bootstrap.example.yaml | 88 +++- src/bootstrap.rs | 769 ++++++++++++++++++++++++++++++---- tests/m25_config_bootstrap.rs | 239 ++++++++++- 6 files changed, 1054 insertions(+), 112 deletions(-) diff --git a/.env.example b/.env.example index 32021ff3..6c28c57d 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,8 @@ ATOM_MIN_PASSWORD_CHARS=8 # ADMIN_ENTITY_ID=00000000-0000-0000-0000-000000000001 # --- Config-file bootstrap ---------------------------------------------- -# Optional. Path to a YAML file describing entities and their credentials to +# Optional. Path to a YAML file describing the RBAC baseline (tenants, +# entities + credentials, groups, permission blocks, roles, policies) to # provision at startup, applied idempotently after migrations. See # bootstrap.example.yaml. Leave unset to rely on the env-var bootstrap above. # ATOM_BOOTSTRAP_FILE=./bootstrap.yaml diff --git a/AGENTS.md b/AGENTS.md index 4e55d83a..02228279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,12 @@ Lightweight replacement for Keycloak — single Rust binary, single Postgres dat ``` src/ - main.rs — startup: config, DB pool, migrations, admin bootstrap, router + main.rs — startup: config, DB pool, migrations, admin bootstrap, + │ config-file bootstrap, router config.rs — Config struct, reads env vars (incl. ADMIN_ENTITY_ID, ADMIN_SECRET) + bootstrap.rs — optional idempotent YAML bootstrap (ATOM_BOOTSTRAP_FILE): + │ tenants, entities+credentials, groups, permission blocks, + │ roles, role assignments, direct policies state.rs — AppState (pool + config), cloned into every handler routes.rs — live router: GraphQL, gRPC, auth/session REST, custom endpoints, │ JWKS, health/live, health/ready, cert artifacts (rate-limit + CORS layers) @@ -218,6 +222,7 @@ Environment variables: copy `.env.example` to `.env`. Required: `DATABASE_URL`. Optional: `ADMIN_SECRET` — if set, bootstraps the admin entity's password on first boot. Optional: `ADMIN_ENTITY_ID` — override the seeded admin UUID (default `00000000-0000-0000-0000-000000000001`). +Optional: `ATOM_BOOTSTRAP_FILE` — path to a YAML file (`src/bootstrap.rs`) applied idempotently after migrations to provision the RBAC baseline (tenants, entities + credentials, groups, permission blocks, roles, policies); runs alongside the env-var bootstrap. See `bootstrap.example.yaml`. The runtime is production-hardened: configurable DB pool, five-category IP rate limiter, GraphQL depth/complexity/introspection limits (introspection **off** by default — opt in with `ATOM_GRAPHQL_INTROSPECTION_ENABLED=true`), per-route body limits, encryption at rest for recoverable secrets (signing keys, shared keys), audit retention, a `/health/ready` readiness probe, and graceful shutdown on SIGINT/SIGTERM (both the HTTP and gRPC servers drain in-flight requests before exit). diff --git a/README.md b/README.md index e41ab916..aa1dcace 100644 --- a/README.md +++ b/README.md @@ -659,7 +659,7 @@ Generic application mapping: | `ADMIN_SECRET` | *(optional)* | Seeds the admin password on first boot | | `ADMIN_ENTITY_ID` | `00000000-0000-0000-0000-000000000001` | Override seeded admin UUID | | `ATOM_SERVICE_SECRET` / `ATOM_SERVICE_ENTITY_ID` | *(optional)* / seeded service UUID | Seeds a service entity password on first boot | -| `ATOM_BOOTSTRAP_FILE` | *(optional)* | Path to a YAML file provisioning entities + credentials at startup (idempotent) | +| `ATOM_BOOTSTRAP_FILE` | *(optional)* | Path to a YAML file provisioning the RBAC baseline at startup (idempotent) | | `ATOM_MIN_PASSWORD_CHARS` | `12` | Minimum password length | | `ATOM_CORS_ALLOWED_ORIGINS` | `ATOM_PUBLIC_BASE_URL` | Comma-separated allowed CORS origins | | `ATOM_AUTH_COOKIE_SECURE` / `ATOM_AUTH_COOKIE_DOMAIN` | auto-detect HTTPS / *(unset)* | Auth cookie options for UI flows | @@ -782,7 +782,8 @@ upstream proxy that sanitizes those headers. Standing up a fresh deployment no longer requires driving the API by hand or juggling one `*_SECRET` env var per identity. Point Atom at a YAML file and it -provisions the declared entities and their credentials at startup: +provisions the whole RBAC baseline — tenants, entities and their credentials, +principal groups, permission blocks, roles and policies — at startup: ```bash ATOM_BOOTSTRAP_FILE=./bootstrap.yaml @@ -790,6 +791,11 @@ ATOM_BOOTSTRAP_FILE=./bootstrap.yaml ```yaml # bootstrap.yaml +tenants: + - id: 33333333-3333-3333-3333-333333333333 + name: factory + alias: factory + entities: # Attach a password to the pre-seeded admin (replaces ADMIN_SECRET). - id: 00000000-0000-0000-0000-000000000001 @@ -798,22 +804,50 @@ entities: credentials: - kind: password secret: change-me-please - - # A new service identity with a machine shared key. - - id: 11111111-1111-1111-1111-111111111111 - kind: service - name: ingest-service + # A device inside the factory tenant with a machine shared key. + - id: 22222222-2222-2222-2222-222222222222 + kind: device + name: gateway-01 + tenant_id: 33333333-3333-3333-3333-333333333333 credentials: - kind: shared_key key: replace-with-a-strong-machine-secret - description: telemetry ingest pipeline + +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: { mode: object_type, tenant_id: 33333333-3333-3333-3333-333333333333, object_kind: resource, object_type: resource:channel } + actions: [publish, subscribe] + effect: allow + +roles: + - id: 55555555-5555-5555-5555-555555555555 + name: publisher + tenant_id: 33333333-3333-3333-3333-333333333333 + permission_blocks: [44444444-4444-4444-4444-444444444444] + +role_assignments: + - id: 66666666-6666-6666-6666-666666666666 + tenant_id: 33333333-3333-3333-3333-333333333333 + subject: { kind: entity, id: 22222222-2222-2222-2222-222222222222 } + role_id: 55555555-5555-5555-5555-555555555555 ``` -The file is applied once, right after migrations, and is **idempotent**: each -entity is keyed on its stable UUID and each credential is created only when the -entity has no active credential of that kind, so re-running against an -already-provisioned database is a no-op and never clobbers runtime changes. It -runs alongside the env-var bootstrap above, not instead of it. `shared_key` +Sections are applied in dependency order: `tenants` → `entities` (+ +credentials) → `groups` (+ members) → `permission_blocks` (+ actions) → `roles` +(+ block links) → `role_assignments` → `direct_policies`. Every section is +optional, and records may reference rows that already exist in the database +(for example the pre-seeded `admin` entity or `atom-admin` role). + +The file is applied once, right after migrations, and is **idempotent**: every +record is keyed on a stable UUID and inserted with `ON CONFLICT DO NOTHING` +(credentials are created only when the entity has no active credential of that +kind), so re-running against an already-provisioned database is a no-op and +never clobbers runtime changes. It runs alongside the env-var bootstrap above, +not instead of it. + +Notes: permission-block `scope.mode` is one of `platform`, `tenant`, +`object_kind`, `object_type` or `object` (group-relative scopes are not covered +by bootstrap); block `actions` are seeded action names; `shared_key` credentials are only valid for machine (non-human) entities and require an explicit `key`. Secrets are written in plaintext just like `ADMIN_SECRET`, so treat the file as a secret (restrict its mode, keep it out of version control). diff --git a/bootstrap.example.yaml b/bootstrap.example.yaml index 32285034..6afaa892 100644 --- a/bootstrap.example.yaml +++ b/bootstrap.example.yaml @@ -2,15 +2,33 @@ # # Point Atom at this file with ATOM_BOOTSTRAP_FILE=/path/to/bootstrap.yaml. # It is applied once at startup, right after migrations, and is idempotent: -# entities/credentials that already exist are left untouched, so it is safe to -# leave configured across restarts. +# every record is keyed on a stable UUID and inserted with ON CONFLICT DO +# NOTHING, so existing rows are left untouched and it is safe to leave +# configured across restarts. Records may reference rows that already exist in +# the database (e.g. the pre-seeded `admin` entity or `atom-admin` role). +# +# Sections are applied in dependency order: +# tenants -> entities (+credentials) -> groups (+members) +# -> permission_blocks (+actions) -> roles (+block links) +# -> role_assignments -> direct_policies # # Secrets are declared inline (just like ADMIN_SECRET) — protect this file: # mount it as a secret, keep it out of version control, restrict its mode. +# --- Tenants (domains) --------------------------------------------------- +tenants: + - id: 33333333-3333-3333-3333-333333333333 + name: factory + alias: factory + tags: [demo, factory] + attributes: + region: eu + plan: gold + # status: active # active | inactive | frozen (default active) + +# --- Entities (+ credentials) -------------------------------------------- entities: - # Attach a password to the pre-seeded platform admin (well-known seed UUID). - # This replaces setting ADMIN_SECRET. + # Attach a password to the pre-seeded platform admin (replaces ADMIN_SECRET). - id: 00000000-0000-0000-0000-000000000001 kind: human name: admin @@ -18,23 +36,57 @@ entities: - kind: password secret: change-me-please - # A brand-new service identity with a machine shared key. - - id: 11111111-1111-1111-1111-111111111111 - kind: service - name: ingest-service - alias: ingest-service - attributes: - system: true - purpose: telemetry-ingest - credentials: - - kind: shared_key - key: replace-with-a-strong-machine-secret - description: telemetry ingest pipeline - - # A device identity with a shared key. + # A device placed inside the factory tenant, with a machine shared key. - id: 22222222-2222-2222-2222-222222222222 kind: device name: gateway-01 + tenant_id: 33333333-3333-3333-3333-333333333333 credentials: - kind: shared_key key: replace-with-a-strong-device-secret + +# --- Principal (subject) groups (+ members) ------------------------------ +groups: + - id: 77777777-7777-7777-7777-777777777777 + name: publishers + tenant_id: 33333333-3333-3333-3333-333333333333 + description: Devices allowed to publish telemetry + members: + - 22222222-2222-2222-2222-222222222222 + +# --- Permission blocks (scope + actions + effect + conditions) ----------- +# Shared: link to roles below and/or grant directly via direct_policies. +# scope.mode is one of: platform | tenant | object_kind | object_type | object +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: object_type + tenant_id: 33333333-3333-3333-3333-333333333333 + object_kind: resource + object_type: resource:channel + actions: [publish, subscribe] + effect: allow + # conditions: {} # flat ABAC object, ANDed; defaults to {} (always matches) + +# --- Roles (named collections of permission blocks) ---------------------- +roles: + - id: 55555555-5555-5555-5555-555555555555 + name: publisher + tenant_id: 33333333-3333-3333-3333-333333333333 + description: Devices that can publish telemetry + permission_blocks: + - 44444444-4444-4444-4444-444444444444 + +# --- Role assignments (subject gets a role) ------------------------------ +role_assignments: + - id: 66666666-6666-6666-6666-666666666666 + tenant_id: 33333333-3333-3333-3333-333333333333 + subject: { kind: entity, id: 22222222-2222-2222-2222-222222222222 } + role_id: 55555555-5555-5555-5555-555555555555 + +# --- Direct policies (subject gets one permission block directly) -------- +direct_policies: + - id: 88888888-8888-8888-8888-888888888888 + tenant_id: 33333333-3333-3333-3333-333333333333 + subject: { kind: group, id: 77777777-7777-7777-7777-777777777777 } + permission_block_id: 44444444-4444-4444-4444-444444444444 diff --git a/src/bootstrap.rs b/src/bootstrap.rs index d0becc49..7d80d12c 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -2,35 +2,65 @@ //! //! Standing up a fresh Atom deployment previously meant either setting a handful //! of `*_SECRET` env vars or driving the API by hand to create the initial -//! entities and their credentials. Neither is friendly for repeatable, reviewable -//! platform management. +//! tenants, entities, roles and policies. Neither is friendly for repeatable, +//! reviewable platform management. //! //! This module lets an operator describe the desired baseline in a single YAML //! file (pointed to by `ATOM_BOOTSTRAP_FILE`). The file is loaded once at -//! startup, right after migrations, and applied **idempotently**: re-running it -//! against an already-provisioned database is a no-op. Existing entities and -//! credentials are never mutated or clobbered — bootstrap only fills in what is -//! missing, keyed on the stable UUIDs declared in the file. +//! startup, right after migrations, and applied **idempotently**: every record +//! is keyed on a stable UUID and inserted with `ON CONFLICT DO NOTHING`, so +//! re-running against an already-provisioned database is a no-op and never +//! clobbers runtime changes. It runs *alongside* the env-var bootstrap, not +//! instead of it. +//! +//! It provisions the full RBAC graph, applied in dependency order: +//! tenants → entities (+ credentials) → principal groups (+ members) → +//! permission blocks (+ actions) → roles (+ block links) → role assignments → +//! direct policies. Records may reference rows that already exist in the +//! database (for example the pre-seeded `admin` entity or `atom-admin` role); +//! foreign-key violations for genuinely missing references abort startup. //! //! ## Example //! //! ```yaml +//! tenants: +//! - id: 33333333-3333-3333-3333-333333333333 +//! name: factory +//! alias: factory +//! //! entities: -//! - id: 00000000-0000-0000-0000-000000000001 -//! kind: human -//! name: admin -//! credentials: -//! - kind: password -//! secret: change-me-please -//! - id: 11111111-1111-1111-1111-111111111111 -//! kind: service -//! name: ingest-service +//! - id: 22222222-2222-2222-2222-222222222222 +//! kind: device +//! name: gateway-01 +//! tenant_id: 33333333-3333-3333-3333-333333333333 //! credentials: //! - kind: shared_key -//! key: super-secret-machine-key -//! description: ingest pipeline +//! key: a-strong-device-secret +//! +//! permission_blocks: +//! - id: 44444444-4444-4444-4444-444444444444 +//! scope: +//! mode: object_type +//! tenant_id: 33333333-3333-3333-3333-333333333333 +//! object_kind: resource +//! object_type: resource:channel +//! actions: [publish, subscribe] +//! effect: allow +//! +//! roles: +//! - id: 55555555-5555-5555-5555-555555555555 +//! name: publisher +//! tenant_id: 33333333-3333-3333-3333-333333333333 +//! permission_blocks: [44444444-4444-4444-4444-444444444444] +//! +//! role_assignments: +//! - id: 66666666-6666-6666-6666-666666666666 +//! tenant_id: 33333333-3333-3333-3333-333333333333 +//! subject: { kind: entity, id: 22222222-2222-2222-2222-222222222222 } +//! role_id: 55555555-5555-5555-5555-555555555555 //! ``` +use std::collections::HashSet; use std::path::Path; use anyhow::{anyhow, bail, Context, Result}; @@ -42,36 +72,65 @@ use uuid::Uuid; use crate::config::SigningKeyConfig; use crate::identity; use crate::models::alias::validate_alias_opt; -use crate::models::enums::{CredentialKind, EntityKind, EntityStatus}; +use crate::models::enums::{ + CredentialKind, Effect, EntityKind, EntityStatus, SubjectKind, TenantStatus, +}; use crate::models::token::CreateSharedKey; -/// Root of the bootstrap document. -#[derive(Debug, Clone, Deserialize, PartialEq)] +/// Root of the bootstrap document. Every section is optional. +#[derive(Debug, Clone, Deserialize, PartialEq, Default)] #[serde(deny_unknown_fields)] pub struct BootstrapConfig { + #[serde(default)] + pub tenants: Vec, #[serde(default)] pub entities: Vec, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub permission_blocks: Vec, + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub role_assignments: Vec, + #[serde(default)] + pub direct_policies: Vec, } -/// A single entity to ensure exists, together with its credentials. +/// A tenant (domain). `None` `tenant_id` on other records means platform scope. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapTenant { + pub id: Uuid, + pub name: String, + #[serde(default)] + pub alias: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub attributes: Option, + #[serde(default)] + pub status: TenantStatus, +} + +/// An entity, together with its credentials. #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct BootstrapEntity { - /// Stable UUID. Required so re-runs are deterministic and idempotent — it is - /// the key we upsert on. Use the well-known seed UUIDs to attach credentials - /// to the pre-seeded `admin`/`example-service` entities. + /// Stable UUID — the key we upsert on. Use the well-known seed UUIDs to + /// attach credentials to the pre-seeded `admin`/`example-service` entities. pub id: Uuid, pub kind: EntityKind, pub name: String, - /// Optional human-friendly slug (unique per tenant). Validated with the same - /// rules as the API. #[serde(default)] pub alias: Option, #[serde(default)] pub status: EntityStatus, - /// Free-form JSON object. Defaults to `{}`. #[serde(default)] pub attributes: Option, + /// Owning tenant. `None` places the entity at platform scope. + #[serde(default)] + pub tenant_id: Option, #[serde(default)] pub credentials: Vec, } @@ -96,61 +155,295 @@ pub enum BootstrapCredential { }, } +/// A principal (subject) group and its entity members. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapGroup { + pub id: Uuid, + pub name: String, + #[serde(default)] + pub tenant_id: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub attributes: Option, + /// Entity IDs that belong to this group. + #[serde(default)] + pub members: Vec, +} + +/// A permission block: scope + actions + effect + conditions. Shared — link it +/// to roles (`roles[].permission_blocks`) and/or grant it directly to subjects +/// (`direct_policies[].permission_block_id`). +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapPermissionBlock { + pub id: Uuid, + pub scope: BootstrapScope, + /// Action names (e.g. `read`, `publish`). Resolved to seeded action IDs. + #[serde(default)] + pub actions: Vec, + #[serde(default)] + pub effect: Effect, + #[serde(default)] + pub conditions: Option, +} + +/// The subset of permission-block scope modes bootstrap supports. The advanced +/// group-relative scopes are intentionally excluded — they require object +/// groups that bootstrap does not manage. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScopeMode { + Platform, + Tenant, + ObjectKind, + ObjectType, + Object, +} + +impl ScopeMode { + fn as_str(self) -> &'static str { + match self { + ScopeMode::Platform => "platform", + ScopeMode::Tenant => "tenant", + ScopeMode::ObjectKind => "object_kind", + ScopeMode::ObjectType => "object_type", + ScopeMode::Object => "object", + } + } +} + +/// Scope of a permission block. Which fields are required depends on `mode`; +/// [`BootstrapScope::validate`] mirrors the database CHECK constraint so a bad +/// combination is rejected before insert. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapScope { + pub mode: ScopeMode, + #[serde(default)] + pub tenant_id: Option, + #[serde(default)] + pub object_kind: Option, + #[serde(default)] + pub object_type: Option, + #[serde(default)] + pub object_id: Option, +} + +impl BootstrapScope { + fn validate(&self, block_id: Uuid) -> Result<()> { + let has_kind = self.object_kind.is_some(); + let has_type = self.object_type.is_some(); + let has_object = self.object_id.is_some(); + let has_tenant = self.tenant_id.is_some(); + let require = |cond: bool, msg: &str| -> Result<()> { + if cond { + Ok(()) + } else { + Err(anyhow!("permission block {block_id}: {msg}")) + } + }; + match self.mode { + ScopeMode::Platform => { + require( + !has_tenant && !has_kind && !has_type && !has_object, + "platform scope takes no tenant_id/object_kind/object_type/object_id", + )?; + } + ScopeMode::Tenant => { + require(has_tenant, "tenant scope requires tenant_id")?; + require( + !has_kind && !has_type && !has_object, + "tenant scope takes no object_kind/object_type/object_id", + )?; + } + ScopeMode::ObjectKind => { + require( + has_tenant && has_kind, + "object_kind scope requires tenant_id and object_kind", + )?; + require( + !has_type && !has_object, + "object_kind scope takes no object_type/object_id", + )?; + } + ScopeMode::ObjectType => { + require( + has_tenant && has_kind && has_type, + "object_type scope requires tenant_id, object_kind and object_type", + )?; + require(!has_object, "object_type scope takes no object_id")?; + } + ScopeMode::Object => { + require(has_object, "object scope requires object_id")?; + require( + !has_kind && !has_type, + "object scope takes no object_kind/object_type", + )?; + } + } + Ok(()) + } +} + +/// A role, optionally linked to permission blocks defined above. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapRole { + pub id: Uuid, + pub name: String, + #[serde(default)] + pub tenant_id: Option, + #[serde(default)] + pub description: Option, + /// IDs of permission blocks to attach to this role. + #[serde(default)] + pub permission_blocks: Vec, +} + +/// The subject of an assignment or direct policy. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapSubject { + pub kind: SubjectKind, + pub id: Uuid, +} + +/// Grants a role to a subject. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapRoleAssignment { + pub id: Uuid, + #[serde(default)] + pub tenant_id: Option, + pub subject: BootstrapSubject, + pub role_id: Uuid, +} + +/// Grants a permission block directly to a subject. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapDirectPolicy { + pub id: Uuid, + #[serde(default)] + pub tenant_id: Option, + pub subject: BootstrapSubject, + pub permission_block_id: Uuid, +} + impl BootstrapConfig { /// Structural validation performed before touching the database, so a /// malformed file aborts startup with a clear message instead of a partial, /// half-applied bootstrap. pub fn validate(&self) -> Result<()> { - let mut seen_ids = std::collections::HashSet::new(); - for entity in &self.entities { - if !seen_ids.insert(entity.id) { - bail!("duplicate bootstrap entity id {}", entity.id); + unique_ids(self.tenants.iter().map(|t| t.id), "tenant")?; + unique_ids(self.entities.iter().map(|e| e.id), "entity")?; + unique_ids(self.groups.iter().map(|g| g.id), "group")?; + unique_ids( + self.permission_blocks.iter().map(|b| b.id), + "permission block", + )?; + unique_ids(self.roles.iter().map(|r| r.id), "role")?; + unique_ids( + self.role_assignments.iter().map(|a| a.id), + "role assignment", + )?; + unique_ids(self.direct_policies.iter().map(|p| p.id), "direct policy")?; + + for tenant in &self.tenants { + if tenant.name.trim().is_empty() { + bail!("bootstrap tenant {} has an empty name", tenant.id); } - if entity.name.trim().is_empty() { - bail!("bootstrap entity {} has an empty name", entity.id); + check_object_attributes(&tenant.attributes, "tenant", tenant.id)?; + } + for entity in &self.entities { + entity.validate()?; + } + for group in &self.groups { + if group.name.trim().is_empty() { + bail!("bootstrap group {} has an empty name", group.id); } - if let Some(attrs) = &entity.attributes { - if !attrs.is_object() { + check_object_attributes(&group.attributes, "group", group.id)?; + } + for block in &self.permission_blocks { + block.scope.validate(block.id)?; + if let Some(conditions) = &block.conditions { + if !conditions.is_object() { bail!( - "bootstrap entity {} attributes must be a JSON object", - entity.id + "permission block {} conditions must be a JSON object", + block.id ); } } + } + for role in &self.roles { + if role.name.trim().is_empty() { + bail!("bootstrap role {} has an empty name", role.id); + } + } + Ok(()) + } +} + +impl BootstrapEntity { + fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + bail!("bootstrap entity {} has an empty name", self.id); + } + check_object_attributes(&self.attributes, "entity", self.id)?; - let mut passwords = 0; - let mut shared_keys = 0; - for cred in &entity.credentials { - match cred { - BootstrapCredential::Password { .. } => passwords += 1, - BootstrapCredential::SharedKey { .. } => { - shared_keys += 1; - if !CredentialKind::SharedKey.allowed_for(&entity.kind) { - bail!( - "bootstrap entity {} is a human; shared keys are only valid for machine entities", - entity.id - ); - } + let mut passwords = 0; + let mut shared_keys = 0; + for cred in &self.credentials { + match cred { + BootstrapCredential::Password { .. } => passwords += 1, + BootstrapCredential::SharedKey { .. } => { + shared_keys += 1; + if !CredentialKind::SharedKey.allowed_for(&self.kind) { + bail!( + "bootstrap entity {} is a human; shared keys are only valid for machine entities", + self.id + ); } } } - if passwords > 1 { - bail!( - "bootstrap entity {} declares more than one password credential", - entity.id - ); - } - if shared_keys > 1 { - bail!( - "bootstrap entity {} declares more than one shared_key credential", - entity.id - ); - } + } + if passwords > 1 { + bail!( + "bootstrap entity {} declares more than one password credential", + self.id + ); + } + if shared_keys > 1 { + bail!( + "bootstrap entity {} declares more than one shared_key credential", + self.id + ); } Ok(()) } } +fn unique_ids(ids: impl Iterator, label: &str) -> Result<()> { + let mut seen = HashSet::new(); + for id in ids { + if !seen.insert(id) { + bail!("duplicate bootstrap {label} id {id}"); + } + } + Ok(()) +} + +fn check_object_attributes(attributes: &Option, label: &str, id: Uuid) -> Result<()> { + if let Some(attrs) = attributes { + if !attrs.is_object() { + bail!("bootstrap {label} {id} attributes must be a JSON object"); + } + } + Ok(()) +} + /// Read and parse a bootstrap file, validating its structure. pub fn load(path: &Path) -> Result { let contents = std::fs::read_to_string(path) @@ -164,18 +457,64 @@ fn parse(contents: &str) -> Result { Ok(cfg) } -/// Apply the bootstrap config against the database. Idempotent. +/// Apply the bootstrap config against the database, in dependency order. +/// Idempotent. pub async fn apply( pool: &PgPool, signing_keys: &SigningKeyConfig, cfg: &BootstrapConfig, ) -> Result<()> { + for tenant in &cfg.tenants { + ensure_tenant(pool, tenant).await?; + } for entity in &cfg.entities { ensure_entity(pool, entity).await?; for cred in &entity.credentials { ensure_credential(pool, signing_keys, entity, cred).await?; } } + for group in &cfg.groups { + ensure_group(pool, group).await?; + } + for block in &cfg.permission_blocks { + ensure_permission_block(pool, block).await?; + } + for role in &cfg.roles { + ensure_role(pool, role).await?; + } + for assignment in &cfg.role_assignments { + ensure_role_assignment(pool, assignment).await?; + } + for policy in &cfg.direct_policies { + ensure_direct_policy(pool, policy).await?; + } + Ok(()) +} + +async fn ensure_tenant(pool: &PgPool, tenant: &BootstrapTenant) -> Result<()> { + let alias = validate_alias_opt(tenant.alias.clone()) + .map_err(|e| anyhow!("bootstrap tenant {}: {e}", tenant.id))?; + let attributes = tenant + .attributes + .clone() + .unwrap_or_else(|| serde_json::json!({})); + + let result = sqlx::query( + r#"INSERT INTO tenants (id, name, alias, status, tags, attributes) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(tenant.id) + .bind(&tenant.name) + .bind(alias) + .bind(&tenant.status) + .bind(&tenant.tags) + .bind(attributes) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap tenant {}", tenant.id))?; + + log_upsert(result.rows_affected(), "tenant", tenant.id); Ok(()) } @@ -190,25 +529,22 @@ async fn ensure_entity(pool: &PgPool, entity: &BootstrapEntity) -> Result<()> { .unwrap_or_else(|| serde_json::json!({})); let result = sqlx::query( - r#"INSERT INTO entities (id, kind, name, alias, status, attributes) - VALUES ($1, $2, $3, $4, $5, $6) + r#"INSERT INTO entities (id, kind, name, alias, tenant_id, status, attributes) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING"#, ) .bind(entity.id) .bind(&entity.kind) .bind(&entity.name) .bind(alias) + .bind(entity.tenant_id) .bind(&entity.status) .bind(attributes) .execute(pool) .await .with_context(|| format!("failed to insert bootstrap entity {}", entity.id))?; - if result.rows_affected() == 0 { - tracing::info!(entity_id = %entity.id, "bootstrap: entity already present, skipped"); - } else { - tracing::info!(entity_id = %entity.id, kind = ?entity.kind, "bootstrap: entity created"); - } + log_upsert(result.rows_affected(), "entity", entity.id); Ok(()) } @@ -255,6 +591,180 @@ async fn ensure_credential( Ok(()) } +async fn ensure_group(pool: &PgPool, group: &BootstrapGroup) -> Result<()> { + let attributes = group + .attributes + .clone() + .unwrap_or_else(|| serde_json::json!({})); + + let result = sqlx::query( + r#"INSERT INTO principal_groups (id, name, tenant_id, description, attributes) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(group.id) + .bind(&group.name) + .bind(group.tenant_id) + .bind(&group.description) + .bind(attributes) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap group {}", group.id))?; + log_upsert(result.rows_affected(), "group", group.id); + + for entity_id in &group.members { + sqlx::query( + r#"INSERT INTO principal_group_members (group_id, entity_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING"#, + ) + .bind(group.id) + .bind(entity_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to add entity {entity_id} to bootstrap group {}", + group.id + ) + })?; + } + Ok(()) +} + +async fn ensure_permission_block(pool: &PgPool, block: &BootstrapPermissionBlock) -> Result<()> { + let conditions = block + .conditions + .clone() + .unwrap_or_else(|| serde_json::json!({})); + let scope = &block.scope; + + let result = sqlx::query( + r#"INSERT INTO permission_blocks + (id, tenant_id, scope_mode, object_kind, object_type, object_id, effect, conditions) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(block.id) + .bind(scope.tenant_id) + .bind(scope.mode.as_str()) + .bind(&scope.object_kind) + .bind(&scope.object_type) + .bind(scope.object_id) + .bind(&block.effect) + .bind(conditions) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap permission block {}", block.id))?; + log_upsert(result.rows_affected(), "permission block", block.id); + + for action_name in &block.actions { + let action_id: Uuid = sqlx::query_scalar("SELECT id FROM actions WHERE name = $1") + .bind(action_name) + .fetch_optional(pool) + .await + .with_context(|| format!("failed to resolve action {action_name}"))? + .ok_or_else(|| { + anyhow!( + "permission block {}: unknown action {action_name}", + block.id + ) + })?; + sqlx::query( + r#"INSERT INTO permission_block_actions (permission_block_id, action_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING"#, + ) + .bind(block.id) + .bind(action_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to attach action {action_name} to permission block {}", + block.id + ) + })?; + } + Ok(()) +} + +async fn ensure_role(pool: &PgPool, role: &BootstrapRole) -> Result<()> { + let result = sqlx::query( + r#"INSERT INTO roles (id, name, tenant_id, description) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(role.id) + .bind(&role.name) + .bind(role.tenant_id) + .bind(&role.description) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap role {}", role.id))?; + log_upsert(result.rows_affected(), "role", role.id); + + for block_id in &role.permission_blocks { + sqlx::query( + r#"INSERT INTO role_permission_blocks (role_id, permission_block_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING"#, + ) + .bind(role.id) + .bind(block_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to link permission block {block_id} to role {}", + role.id + ) + })?; + } + Ok(()) +} + +async fn ensure_role_assignment(pool: &PgPool, assignment: &BootstrapRoleAssignment) -> Result<()> { + let result = sqlx::query( + r#"INSERT INTO role_assignments (id, tenant_id, subject_kind, subject_id, role_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(assignment.id) + .bind(assignment.tenant_id) + .bind(&assignment.subject.kind) + .bind(assignment.subject.id) + .bind(assignment.role_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to insert bootstrap role assignment {}", + assignment.id + ) + })?; + log_upsert(result.rows_affected(), "role assignment", assignment.id); + Ok(()) +} + +async fn ensure_direct_policy(pool: &PgPool, policy: &BootstrapDirectPolicy) -> Result<()> { + let result = sqlx::query( + r#"INSERT INTO direct_policies (id, tenant_id, subject_kind, subject_id, permission_block_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(policy.id) + .bind(policy.tenant_id) + .bind(&policy.subject.kind) + .bind(policy.subject.id) + .bind(policy.permission_block_id) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap direct policy {}", policy.id))?; + log_upsert(result.rows_affected(), "direct policy", policy.id); + Ok(()) +} + async fn active_credential_exists( pool: &PgPool, entity_id: Uuid, @@ -271,10 +781,83 @@ async fn active_credential_exists( Ok(count > 0) } +fn log_upsert(rows_affected: u64, label: &str, id: Uuid) { + if rows_affected == 0 { + tracing::info!(id = %id, "bootstrap: {label} already present, skipped"); + } else { + tracing::info!(id = %id, "bootstrap: {label} created"); + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn parses_full_rbac_graph() { + let yaml = r#" +tenants: + - id: 33333333-3333-3333-3333-333333333333 + name: factory + alias: factory + tags: [demo] + attributes: { region: eu } + +entities: + - id: 22222222-2222-2222-2222-222222222222 + kind: device + name: gateway-01 + tenant_id: 33333333-3333-3333-3333-333333333333 + credentials: + - kind: shared_key + key: a-strong-device-secret + +groups: + - id: 77777777-7777-7777-7777-777777777777 + name: publishers + tenant_id: 33333333-3333-3333-3333-333333333333 + members: + - 22222222-2222-2222-2222-222222222222 + +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: object_type + tenant_id: 33333333-3333-3333-3333-333333333333 + object_kind: resource + object_type: resource:channel + actions: [publish, subscribe] + effect: allow + +roles: + - id: 55555555-5555-5555-5555-555555555555 + name: publisher + tenant_id: 33333333-3333-3333-3333-333333333333 + permission_blocks: [44444444-4444-4444-4444-444444444444] + +role_assignments: + - id: 66666666-6666-6666-6666-666666666666 + tenant_id: 33333333-3333-3333-3333-333333333333 + subject: { kind: entity, id: 22222222-2222-2222-2222-222222222222 } + role_id: 55555555-5555-5555-5555-555555555555 + +direct_policies: + - id: 88888888-8888-8888-8888-888888888888 + tenant_id: 33333333-3333-3333-3333-333333333333 + subject: { kind: group, id: 77777777-7777-7777-7777-777777777777 } + permission_block_id: 44444444-4444-4444-4444-444444444444 +"#; + let cfg = parse(yaml).expect("parse"); + assert_eq!(cfg.tenants.len(), 1); + assert_eq!(cfg.entities.len(), 1); + assert_eq!(cfg.groups[0].members.len(), 1); + assert_eq!(cfg.permission_blocks[0].scope.mode, ScopeMode::ObjectType); + assert_eq!(cfg.permission_blocks[0].effect, Effect::Allow); + assert_eq!(cfg.roles[0].permission_blocks.len(), 1); + assert_eq!(cfg.role_assignments[0].subject.kind, SubjectKind::Entity); + assert_eq!(cfg.direct_policies[0].subject.kind, SubjectKind::Group); + } + #[test] fn parses_entities_with_credentials() { let yaml = r#" @@ -301,7 +884,6 @@ entities: let admin = &cfg.entities[0]; assert_eq!(admin.kind, EntityKind::Human); - assert_eq!(admin.name, "admin"); assert_eq!(admin.status, EntityStatus::Active); assert_eq!( admin.credentials, @@ -324,8 +906,9 @@ entities: #[test] fn empty_document_is_valid_and_empty() { - let cfg = parse("entities: []").expect("parse"); + let cfg = parse("{}").expect("parse"); assert!(cfg.entities.is_empty()); + assert!(cfg.tenants.is_empty()); } #[test] @@ -399,4 +982,46 @@ entities: let err = parse(yaml).expect_err("scalar attributes"); assert!(err.to_string().contains("must be a JSON object")); } + + #[test] + fn platform_scope_rejects_tenant_id() { + let yaml = r#" +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: platform + tenant_id: 33333333-3333-3333-3333-333333333333 + actions: [read] +"#; + let err = parse(yaml).expect_err("platform with tenant"); + assert!(err.to_string().contains("platform scope takes no")); + } + + #[test] + fn object_type_scope_requires_object_kind_and_type() { + let yaml = r#" +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: object_type + tenant_id: 33333333-3333-3333-3333-333333333333 + object_type: resource:channel + actions: [read] +"#; + let err = parse(yaml).expect_err("missing object_kind"); + assert!(err.to_string().contains("object_type scope requires")); + } + + #[test] + fn duplicate_tenant_ids_are_rejected() { + let yaml = r#" +tenants: + - id: 33333333-3333-3333-3333-333333333333 + name: one + - id: 33333333-3333-3333-3333-333333333333 + name: two +"#; + let err = parse(yaml).expect_err("duplicate tenant ids"); + assert!(err.to_string().contains("duplicate bootstrap tenant id")); + } } diff --git a/tests/m25_config_bootstrap.rs b/tests/m25_config_bootstrap.rs index d61e479e..26d2f094 100644 --- a/tests/m25_config_bootstrap.rs +++ b/tests/m25_config_bootstrap.rs @@ -7,9 +7,13 @@ mod common; -use atom::bootstrap::{apply, BootstrapConfig, BootstrapCredential, BootstrapEntity}; +use atom::bootstrap::{ + apply, BootstrapConfig, BootstrapCredential, BootstrapDirectPolicy, BootstrapEntity, + BootstrapGroup, BootstrapPermissionBlock, BootstrapRole, BootstrapRoleAssignment, + BootstrapScope, BootstrapSubject, BootstrapTenant, ScopeMode, +}; use atom::config::Config; -use atom::models::enums::{EntityKind, EntityStatus}; +use atom::models::enums::{EntityKind, EntityStatus, SubjectKind, TenantStatus}; use common::pool; use uuid::Uuid; @@ -24,7 +28,7 @@ async fn count_active_credentials(pool: &sqlx::PgPool, entity_id: Uuid, kind: &s .expect("count credentials") } -fn sample_config(human: Uuid, service: Uuid) -> BootstrapConfig { +fn credentials_config(human: Uuid, service: Uuid) -> BootstrapConfig { BootstrapConfig { entities: vec![ BootstrapEntity { @@ -34,6 +38,7 @@ fn sample_config(human: Uuid, service: Uuid) -> BootstrapConfig { alias: None, status: EntityStatus::Active, attributes: Some(serde_json::json!({ "system": true })), + tenant_id: None, credentials: vec![BootstrapCredential::Password { secret: "bootstrap-pw-123456".to_string(), }], @@ -45,12 +50,14 @@ fn sample_config(human: Uuid, service: Uuid) -> BootstrapConfig { alias: None, status: EntityStatus::Active, attributes: None, + tenant_id: None, credentials: vec![BootstrapCredential::SharedKey { key: "bootstrap-machine-secret".to_string(), description: Some("integration test".to_string()), }], }, ], + ..Default::default() } } @@ -61,7 +68,7 @@ async fn bootstrap_creates_entities_and_credentials() { let signing_keys = Config::for_tests().signing_keys; let human = Uuid::new_v4(); let service = Uuid::new_v4(); - let cfg = sample_config(human, service); + let cfg = credentials_config(human, service); apply(&p, &signing_keys, &cfg) .await @@ -92,7 +99,7 @@ async fn bootstrap_is_idempotent() { let signing_keys = Config::for_tests().signing_keys; let human = Uuid::new_v4(); let service = Uuid::new_v4(); - let cfg = sample_config(human, service); + let cfg = credentials_config(human, service); // Apply twice; the second run must not create duplicate rows. apply(&p, &signing_keys, &cfg).await.expect("first apply"); @@ -117,7 +124,7 @@ async fn bootstrap_does_not_clobber_existing_credentials() { let human = Uuid::new_v4(); let service = Uuid::new_v4(); - apply(&p, &signing_keys, &sample_config(human, service)) + apply(&p, &signing_keys, &credentials_config(human, service)) .await .expect("first apply"); @@ -130,7 +137,7 @@ async fn bootstrap_does_not_clobber_existing_credentials() { // A second run declaring a different secret for the same entity must not // rotate the existing credential — bootstrap only fills in what is missing. - let mut changed = sample_config(human, service); + let mut changed = credentials_config(human, service); changed.entities[0].credentials = vec![BootstrapCredential::Password { secret: "a-totally-different-secret".to_string(), }]; @@ -150,3 +157,221 @@ async fn bootstrap_does_not_clobber_existing_credentials() { ); assert_eq!(count_active_credentials(&p, human, "password").await, 1); } + +/// A full tenant → entity → block → role → assignment graph, ending in a real +/// PDP-visible grant for the assigned entity. +fn rbac_config( + tenant: Uuid, + device: Uuid, + block: Uuid, + role: Uuid, + assignment: Uuid, +) -> BootstrapConfig { + BootstrapConfig { + tenants: vec![BootstrapTenant { + id: tenant, + name: format!("bootstrap-tenant-{tenant}"), + alias: None, + tags: vec!["demo".to_string()], + attributes: None, + status: TenantStatus::Active, + }], + entities: vec![BootstrapEntity { + id: device, + kind: EntityKind::Device, + name: format!("bootstrap-device-{device}"), + alias: None, + status: EntityStatus::Active, + attributes: None, + tenant_id: Some(tenant), + credentials: vec![], + }], + permission_blocks: vec![BootstrapPermissionBlock { + id: block, + scope: BootstrapScope { + mode: ScopeMode::ObjectType, + tenant_id: Some(tenant), + object_kind: Some("resource".to_string()), + object_type: Some("resource:channel".to_string()), + object_id: None, + }, + actions: vec!["publish".to_string(), "subscribe".to_string()], + effect: Default::default(), + conditions: None, + }], + roles: vec![BootstrapRole { + id: role, + name: format!("publisher-{role}"), + tenant_id: Some(tenant), + description: Some("can publish".to_string()), + permission_blocks: vec![block], + }], + role_assignments: vec![BootstrapRoleAssignment { + id: assignment, + tenant_id: Some(tenant), + subject: BootstrapSubject { + kind: SubjectKind::Entity, + id: device, + }, + role_id: role, + }], + ..Default::default() + } +} + +#[tokio::test] +#[ignore] +async fn bootstrap_provisions_full_rbac_graph() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let tenant = Uuid::new_v4(); + let device = Uuid::new_v4(); + let block = Uuid::new_v4(); + let role = Uuid::new_v4(); + let assignment = Uuid::new_v4(); + let cfg = rbac_config(tenant, device, block, role, assignment); + + // Apply twice to prove the whole graph is idempotent. + apply(&p, &signing_keys, &cfg).await.expect("first apply"); + apply(&p, &signing_keys, &cfg).await.expect("second apply"); + + // Rows exist and are linked. + let entity_tenant: Option = + sqlx::query_scalar("SELECT tenant_id FROM entities WHERE id = $1") + .bind(device) + .fetch_one(&p) + .await + .expect("device entity"); + assert_eq!(entity_tenant, Some(tenant)); + + let link_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM role_permission_blocks WHERE role_id = $1 AND permission_block_id = $2", + ) + .bind(role) + .bind(block) + .fetch_one(&p) + .await + .expect("role/block link"); + assert_eq!(link_count, 1, "block linked to role exactly once"); + + let action_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM permission_block_actions WHERE permission_block_id = $1", + ) + .bind(block) + .fetch_one(&p) + .await + .expect("block actions"); + assert_eq!( + action_count, 2, + "publish + subscribe resolved to action rows" + ); + + // End-to-end: the assigned device now effectively holds `publish` via the + // canonical grant expansion the PDP consumes. + let publish_grants: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM subject_effective_grants($1) g + JOIN actions a ON a.id = g.capability_id + WHERE a.name = 'publish' AND g.effect = 'allow'"#, + ) + .bind(device) + .fetch_one(&p) + .await + .expect("effective grants"); + assert!( + publish_grants >= 1, + "device should effectively hold an allow-publish grant" + ); +} + +#[tokio::test] +#[ignore] +async fn bootstrap_supports_group_subjects_and_direct_policies() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let tenant = Uuid::new_v4(); + let device = Uuid::new_v4(); + let block = Uuid::new_v4(); + let group = Uuid::new_v4(); + + let cfg = BootstrapConfig { + tenants: vec![BootstrapTenant { + id: tenant, + name: format!("bootstrap-tenant-{tenant}"), + alias: None, + tags: vec![], + attributes: None, + status: TenantStatus::Active, + }], + entities: vec![BootstrapEntity { + id: device, + kind: EntityKind::Device, + name: format!("bootstrap-device-{device}"), + alias: None, + status: EntityStatus::Active, + attributes: None, + tenant_id: Some(tenant), + credentials: vec![], + }], + groups: vec![BootstrapGroup { + id: group, + name: format!("publishers-{group}"), + tenant_id: Some(tenant), + description: None, + attributes: None, + members: vec![device], + }], + permission_blocks: vec![BootstrapPermissionBlock { + id: block, + scope: BootstrapScope { + mode: ScopeMode::Tenant, + tenant_id: Some(tenant), + object_kind: None, + object_type: None, + object_id: None, + }, + actions: vec!["read".to_string()], + effect: Default::default(), + conditions: None, + }], + direct_policies: vec![BootstrapDirectPolicy { + id: Uuid::new_v4(), + tenant_id: Some(tenant), + subject: BootstrapSubject { + kind: SubjectKind::Group, + id: group, + }, + permission_block_id: block, + }], + ..Default::default() + }; + + apply(&p, &signing_keys, &cfg).await.expect("apply"); + + let member_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM principal_group_members WHERE group_id = $1 AND entity_id = $2", + ) + .bind(group) + .bind(device) + .fetch_one(&p) + .await + .expect("membership"); + assert_eq!(member_count, 1); + + // The device inherits the group's direct policy: it should effectively hold + // an allow-read grant through group membership. + let read_grants: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM subject_effective_grants($1) g + JOIN actions a ON a.id = g.capability_id + WHERE a.name = 'read' AND g.effect = 'allow'"#, + ) + .bind(device) + .fetch_one(&p) + .await + .expect("effective grants"); + assert!( + read_grants >= 1, + "device should inherit read via group direct policy" + ); +} From fd5abbf63b28e23beab5296eb87b2f0e4722628c Mon Sep 17 00:00:00 2001 From: JeffMboya Date: Wed, 15 Jul 2026 20:29:55 +0300 Subject: [PATCH 03/14] feat: bootstrap resources and object groups from the config file Add `resources` and `object_groups` sections so the config file can provision every protected-object kind, not just the identity/RBAC graph. Resources carry kind/name/alias/tenant/owner; object groups group entities and resources (and nest via `parent`) so a permission block can scope to their members. Permission-block scopes gain the group-relative modes (group_direct_objects, group_descendant_objects, group_child_groups, group_descendant_groups) via `scope.group_id`. The *_objects modes require object_type (the scope_ref is `:`), validated up front so a scope can't be silently dead. Applied in dependency order after entities and before permission blocks; all idempotent via ON CONFLICT DO NOTHING. --- AGENTS.md | 5 +- README.md | 29 ++- bootstrap.example.yaml | 45 +++- src/bootstrap.rs | 425 +++++++++++++++++++++++++++++++++- tests/m25_config_bootstrap.rs | 153 +++++++++++- 5 files changed, 628 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02228279..374cab4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ src/ │ config-file bootstrap, router config.rs — Config struct, reads env vars (incl. ADMIN_ENTITY_ID, ADMIN_SECRET) bootstrap.rs — optional idempotent YAML bootstrap (ATOM_BOOTSTRAP_FILE): - │ tenants, entities+credentials, groups, permission blocks, - │ roles, role assignments, direct policies + │ tenants, entities+credentials, resources, principal & + │ object groups, permission blocks, roles, role + │ assignments, direct policies state.rs — AppState (pool + config), cloned into every handler routes.rs — live router: GraphQL, gRPC, auth/session REST, custom endpoints, │ JWKS, health/live, health/ready, cert artifacts (rate-limit + CORS layers) diff --git a/README.md b/README.md index aa1dcace..5af6a9da 100644 --- a/README.md +++ b/README.md @@ -783,7 +783,8 @@ upstream proxy that sanitizes those headers. Standing up a fresh deployment no longer requires driving the API by hand or juggling one `*_SECRET` env var per identity. Point Atom at a YAML file and it provisions the whole RBAC baseline — tenants, entities and their credentials, -principal groups, permission blocks, roles and policies — at startup: +resources, principal groups, object groups, permission blocks, roles and +policies — at startup: ```bash ATOM_BOOTSTRAP_FILE=./bootstrap.yaml @@ -833,10 +834,11 @@ role_assignments: ``` Sections are applied in dependency order: `tenants` → `entities` (+ -credentials) → `groups` (+ members) → `permission_blocks` (+ actions) → `roles` -(+ block links) → `role_assignments` → `direct_policies`. Every section is -optional, and records may reference rows that already exist in the database -(for example the pre-seeded `admin` entity or `atom-admin` role). +credentials) → `resources` → `groups` (+ members) → `object_groups` (+ members, +hierarchy) → `permission_blocks` (+ actions) → `roles` (+ block links) → +`role_assignments` → `direct_policies`. Every section is optional, and records +may reference rows that already exist in the database (for example the +pre-seeded `admin` entity or `atom-admin` role). The file is applied once, right after migrations, and is **idempotent**: every record is keyed on a stable UUID and inserted with `ON CONFLICT DO NOTHING` @@ -846,12 +848,17 @@ never clobbers runtime changes. It runs alongside the env-var bootstrap above, not instead of it. Notes: permission-block `scope.mode` is one of `platform`, `tenant`, -`object_kind`, `object_type` or `object` (group-relative scopes are not covered -by bootstrap); block `actions` are seeded action names; `shared_key` -credentials are only valid for machine (non-human) entities and require an -explicit `key`. Secrets are written in plaintext just like `ADMIN_SECRET`, so -treat the file as a secret (restrict its mode, keep it out of version control). -See [`bootstrap.example.yaml`](bootstrap.example.yaml) for a fuller example. +`object_kind`, `object_type`, `object`, or a group-relative mode +(`group_direct_objects`, `group_descendant_objects`, `group_child_groups`, +`group_descendant_groups`) which scopes to an object group via `scope.group_id` +— the `*_objects` modes also take `object_kind` (`entity`/`resource`) and +`object_type` (e.g. `resource:channel`); block `actions` are seeded action +names. An entity or resource belongs to at most one object group, and an object +group with members must declare `tenant_id`. `shared_key` credentials are only valid for +machine (non-human) entities and require an explicit `key`. Secrets are written +in plaintext just like `ADMIN_SECRET`, so treat the file as a secret (restrict +its mode, keep it out of version control). See +[`bootstrap.example.yaml`](bootstrap.example.yaml) for a fuller example. --- diff --git a/bootstrap.example.yaml b/bootstrap.example.yaml index 6afaa892..5c35f73b 100644 --- a/bootstrap.example.yaml +++ b/bootstrap.example.yaml @@ -8,9 +8,9 @@ # the database (e.g. the pre-seeded `admin` entity or `atom-admin` role). # # Sections are applied in dependency order: -# tenants -> entities (+credentials) -> groups (+members) -# -> permission_blocks (+actions) -> roles (+block links) -# -> role_assignments -> direct_policies +# tenants -> entities (+credentials) -> resources -> groups (+members) +# -> object_groups (+members, hierarchy) -> permission_blocks (+actions) +# -> roles (+block links) -> role_assignments -> direct_policies # # Secrets are declared inline (just like ADMIN_SECRET) — protect this file: # mount it as a secret, keep it out of version control, restrict its mode. @@ -45,6 +45,16 @@ entities: - kind: shared_key key: replace-with-a-strong-device-secret +# --- Resources (protected objects, e.g. channels) ------------------------ +resources: + - id: 99999999-9999-9999-9999-999999999999 + kind: channel + name: temperature + tenant_id: 33333333-3333-3333-3333-333333333333 + owner_id: 22222222-2222-2222-2222-222222222222 + attributes: + topic: temperature + # --- Principal (subject) groups (+ members) ------------------------------ groups: - id: 77777777-7777-7777-7777-777777777777 @@ -54,10 +64,25 @@ groups: members: - 22222222-2222-2222-2222-222222222222 +# --- Object groups (group entities/resources for group-scoped blocks) ---- +# An entity or resource belongs to at most one object group. A group with +# members must declare tenant_id. `parent` nests groups for descendant scopes. +object_groups: + - id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + name: production-channels + tenant_id: 33333333-3333-3333-3333-333333333333 + resources: + - 99999999-9999-9999-9999-999999999999 + # --- Permission blocks (scope + actions + effect + conditions) ----------- # Shared: link to roles below and/or grant directly via direct_policies. -# scope.mode is one of: platform | tenant | object_kind | object_type | object +# scope.mode: platform | tenant | object_kind | object_type | object +# | group_direct_objects | group_descendant_objects +# | group_child_groups | group_descendant_groups +# The group_* modes require scope.group_id (an object group). The +# group_*_objects modes also take object_kind (entity|resource) and object_type. permission_blocks: + # Type-scoped: any channel resource in the tenant. - id: 44444444-4444-4444-4444-444444444444 scope: mode: object_type @@ -68,6 +93,17 @@ permission_blocks: effect: allow # conditions: {} # flat ABAC object, ANDed; defaults to {} (always matches) + # Group-scoped: only the channel resource members of an object group. + - id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb + scope: + mode: group_direct_objects + tenant_id: 33333333-3333-3333-3333-333333333333 + group_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + object_kind: resource + object_type: resource:channel + actions: [read] + effect: allow + # --- Roles (named collections of permission blocks) ---------------------- roles: - id: 55555555-5555-5555-5555-555555555555 @@ -76,6 +112,7 @@ roles: description: Devices that can publish telemetry permission_blocks: - 44444444-4444-4444-4444-444444444444 + - bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb # --- Role assignments (subject gets a role) ------------------------------ role_assignments: diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 7d80d12c..8cdec7c6 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -14,11 +14,12 @@ //! instead of it. //! //! It provisions the full RBAC graph, applied in dependency order: -//! tenants → entities (+ credentials) → principal groups (+ members) → -//! permission blocks (+ actions) → roles (+ block links) → role assignments → -//! direct policies. Records may reference rows that already exist in the -//! database (for example the pre-seeded `admin` entity or `atom-admin` role); -//! foreign-key violations for genuinely missing references abort startup. +//! tenants → entities (+ credentials) → resources → principal groups +//! (+ members) → object groups (+ members, hierarchy) → permission blocks +//! (+ actions) → roles (+ block links) → role assignments → direct policies. +//! Records may reference rows that already exist in the database (for example +//! the pre-seeded `admin` entity or `atom-admin` role); foreign-key violations +//! for genuinely missing references abort startup. //! //! ## Example //! @@ -86,8 +87,12 @@ pub struct BootstrapConfig { #[serde(default)] pub entities: Vec, #[serde(default)] + pub resources: Vec, + #[serde(default)] pub groups: Vec, #[serde(default)] + pub object_groups: Vec, + #[serde(default)] pub permission_blocks: Vec, #[serde(default)] pub roles: Vec, @@ -155,6 +160,26 @@ pub enum BootstrapCredential { }, } +/// A protected resource object (e.g. a `channel`). `kind` is a free-form label. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapResource { + pub id: Uuid, + pub kind: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub alias: Option, + /// Owning tenant. `None` places the resource at platform scope. + #[serde(default)] + pub tenant_id: Option, + /// Optional owning entity. + #[serde(default)] + pub owner_id: Option, + #[serde(default)] + pub attributes: Option, +} + /// A principal (subject) group and its entity members. #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -172,6 +197,32 @@ pub struct BootstrapGroup { pub members: Vec, } +/// An object group: groups entities and/or resources so a single permission +/// block can scope to all of them (and, via `parent`, to descendant groups). +/// An entity or resource belongs to at most one object group. Membership rows +/// require a tenant, so a group with members must declare `tenant_id`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapObjectGroup { + pub id: Uuid, + pub name: String, + #[serde(default)] + pub tenant_id: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub attributes: Option, + /// Parent object group (this group becomes its child in the hierarchy). + #[serde(default)] + pub parent: Option, + /// Entity IDs that belong to this group. + #[serde(default)] + pub entities: Vec, + /// Resource IDs that belong to this group. + #[serde(default)] + pub resources: Vec, +} + /// A permission block: scope + actions + effect + conditions. Shared — link it /// to roles (`roles[].permission_blocks`) and/or grant it directly to subjects /// (`direct_policies[].permission_block_id`). @@ -189,9 +240,9 @@ pub struct BootstrapPermissionBlock { pub conditions: Option, } -/// The subset of permission-block scope modes bootstrap supports. The advanced -/// group-relative scopes are intentionally excluded — they require object -/// groups that bootstrap does not manage. +/// Permission-block scope modes. The `group_*` modes scope a block to the +/// members (or descendant groups) of an object group, referenced by +/// `scope.group_id`. #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ScopeMode { @@ -200,6 +251,15 @@ pub enum ScopeMode { ObjectKind, ObjectType, Object, + /// A namespaced object type among the direct entity/resource members of the + /// object group (needs `object_kind` + `object_type`). + GroupDirectObjects, + /// Like `group_direct_objects`, extended to descendant groups. + GroupDescendantObjects, + /// Direct child groups of the object group. + GroupChildGroups, + /// Descendant groups of the object group. + GroupDescendantGroups, } impl ScopeMode { @@ -210,8 +270,22 @@ impl ScopeMode { ScopeMode::ObjectKind => "object_kind", ScopeMode::ObjectType => "object_type", ScopeMode::Object => "object", + ScopeMode::GroupDirectObjects => "group_direct_objects", + ScopeMode::GroupDescendantObjects => "group_descendant_objects", + ScopeMode::GroupChildGroups => "group_child_groups", + ScopeMode::GroupDescendantGroups => "group_descendant_groups", } } + + fn is_group(self) -> bool { + matches!( + self, + ScopeMode::GroupDirectObjects + | ScopeMode::GroupDescendantObjects + | ScopeMode::GroupChildGroups + | ScopeMode::GroupDescendantGroups + ) + } } /// Scope of a permission block. Which fields are required depends on `mode`; @@ -229,6 +303,9 @@ pub struct BootstrapScope { pub object_type: Option, #[serde(default)] pub object_id: Option, + /// Object group the block scopes to (required by the `group_*` modes). + #[serde(default)] + pub group_id: Option, } impl BootstrapScope { @@ -237,6 +314,7 @@ impl BootstrapScope { let has_type = self.object_type.is_some(); let has_object = self.object_id.is_some(); let has_tenant = self.tenant_id.is_some(); + let has_group = self.group_id.is_some(); let require = |cond: bool, msg: &str| -> Result<()> { if cond { Ok(()) @@ -244,6 +322,10 @@ impl BootstrapScope { Err(anyhow!("permission block {block_id}: {msg}")) } }; + // group_id belongs only to the group_* modes. + if !self.mode.is_group() { + require(!has_group, "only group_* scopes take a group_id")?; + } match self.mode { ScopeMode::Platform => { require( @@ -282,6 +364,38 @@ impl BootstrapScope { "object scope takes no object_kind/object_type", )?; } + ScopeMode::GroupChildGroups | ScopeMode::GroupDescendantGroups => { + require( + has_tenant && has_group, + "group scopes require tenant_id and group_id", + )?; + require( + !has_kind && !has_type && !has_object, + "this group scope takes no object_kind/object_type/object_id", + )?; + } + ScopeMode::GroupDirectObjects | ScopeMode::GroupDescendantObjects => { + require( + has_tenant && has_group, + "group object scopes require tenant_id and group_id", + )?; + let kind_ok = matches!( + self.object_kind.as_deref(), + Some("entity") | Some("resource") + ); + require( + kind_ok, + "group object scopes require object_kind of 'entity' or 'resource'", + )?; + // The scope_ref is `:` (e.g. + // `resource:channel`); without object_type the scope never + // matches, so require it rather than ship a dead grant. + require( + has_type, + "group object scopes require object_type (e.g. 'resource:channel')", + )?; + require(!has_object, "group object scopes take no object_id")?; + } } Ok(()) } @@ -339,7 +453,9 @@ impl BootstrapConfig { pub fn validate(&self) -> Result<()> { unique_ids(self.tenants.iter().map(|t| t.id), "tenant")?; unique_ids(self.entities.iter().map(|e| e.id), "entity")?; + unique_ids(self.resources.iter().map(|r| r.id), "resource")?; unique_ids(self.groups.iter().map(|g| g.id), "group")?; + unique_ids(self.object_groups.iter().map(|g| g.id), "object group")?; unique_ids( self.permission_blocks.iter().map(|b| b.id), "permission block", @@ -360,12 +476,40 @@ impl BootstrapConfig { for entity in &self.entities { entity.validate()?; } + for resource in &self.resources { + if resource.kind.trim().is_empty() { + bail!("bootstrap resource {} has an empty kind", resource.id); + } + check_object_attributes(&resource.attributes, "resource", resource.id)?; + } for group in &self.groups { if group.name.trim().is_empty() { bail!("bootstrap group {} has an empty name", group.id); } check_object_attributes(&group.attributes, "group", group.id)?; } + for group in &self.object_groups { + if group.name.trim().is_empty() { + bail!("bootstrap object group {} has an empty name", group.id); + } + check_object_attributes(&group.attributes, "object group", group.id)?; + if group.parent == Some(group.id) { + bail!( + "bootstrap object group {} cannot be its own parent", + group.id + ); + } + // Membership rows carry a NOT NULL tenant_id, so a group with + // members must declare its tenant. + if group.tenant_id.is_none() + && (!group.entities.is_empty() || !group.resources.is_empty()) + { + bail!( + "bootstrap object group {} has members but no tenant_id", + group.id + ); + } + } for block in &self.permission_blocks { block.scope.validate(block.id)?; if let Some(conditions) = &block.conditions { @@ -473,9 +617,20 @@ pub async fn apply( ensure_credential(pool, signing_keys, entity, cred).await?; } } + for resource in &cfg.resources { + ensure_resource(pool, resource).await?; + } for group in &cfg.groups { ensure_group(pool, group).await?; } + // Object group rows first, then hierarchy/membership, so a parent declared + // later in the file still resolves. + for group in &cfg.object_groups { + ensure_object_group(pool, group).await?; + } + for group in &cfg.object_groups { + ensure_object_group_links(pool, group).await?; + } for block in &cfg.permission_blocks { ensure_permission_block(pool, block).await?; } @@ -632,6 +787,122 @@ async fn ensure_group(pool: &PgPool, group: &BootstrapGroup) -> Result<()> { Ok(()) } +async fn ensure_resource(pool: &PgPool, resource: &BootstrapResource) -> Result<()> { + let alias = validate_alias_opt(resource.alias.clone()) + .map_err(|e| anyhow!("bootstrap resource {}: {e}", resource.id))?; + let attributes = resource + .attributes + .clone() + .unwrap_or_else(|| serde_json::json!({})); + + let result = sqlx::query( + r#"INSERT INTO resources (id, kind, name, alias, tenant_id, owner_id, attributes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(resource.id) + .bind(&resource.kind) + .bind(&resource.name) + .bind(alias) + .bind(resource.tenant_id) + .bind(resource.owner_id) + .bind(attributes) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap resource {}", resource.id))?; + log_upsert(result.rows_affected(), "resource", resource.id); + Ok(()) +} + +/// Insert the object group row only. Membership and hierarchy are applied in a +/// second pass ([`ensure_object_group_links`]) so a parent declared later in the +/// file still resolves. +async fn ensure_object_group(pool: &PgPool, group: &BootstrapObjectGroup) -> Result<()> { + let attributes = group + .attributes + .clone() + .unwrap_or_else(|| serde_json::json!({})); + + let result = sqlx::query( + r#"INSERT INTO object_groups (id, name, tenant_id, description, attributes) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(group.id) + .bind(&group.name) + .bind(group.tenant_id) + .bind(&group.description) + .bind(attributes) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap object group {}", group.id))?; + log_upsert(result.rows_affected(), "object group", group.id); + Ok(()) +} + +/// Apply an object group's parent link and entity/resource membership. An entity +/// or resource belongs to at most one object group, so membership rows conflict +/// on the member id and are left untouched if already present. +async fn ensure_object_group_links(pool: &PgPool, group: &BootstrapObjectGroup) -> Result<()> { + if let Some(parent_id) = group.parent { + sqlx::query( + r#"INSERT INTO object_group_hierarchy (parent_id, child_id, tenant_id) + VALUES ($1, $2, $3) + ON CONFLICT (child_id) DO NOTHING"#, + ) + .bind(parent_id) + .bind(group.id) + .bind(group.tenant_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to link object group {} under parent {parent_id}", + group.id + ) + })?; + } + + for entity_id in &group.entities { + sqlx::query( + r#"INSERT INTO object_group_entities (group_id, entity_id, tenant_id) + VALUES ($1, $2, $3) + ON CONFLICT (entity_id) DO NOTHING"#, + ) + .bind(group.id) + .bind(entity_id) + .bind(group.tenant_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to add entity {entity_id} to object group {}", + group.id + ) + })?; + } + + for resource_id in &group.resources { + sqlx::query( + r#"INSERT INTO object_group_resources (group_id, resource_id, tenant_id) + VALUES ($1, $2, $3) + ON CONFLICT (resource_id) DO NOTHING"#, + ) + .bind(group.id) + .bind(resource_id) + .bind(group.tenant_id) + .execute(pool) + .await + .with_context(|| { + format!( + "failed to add resource {resource_id} to object group {}", + group.id + ) + })?; + } + Ok(()) +} + async fn ensure_permission_block(pool: &PgPool, block: &BootstrapPermissionBlock) -> Result<()> { let conditions = block .conditions @@ -641,8 +912,8 @@ async fn ensure_permission_block(pool: &PgPool, block: &BootstrapPermissionBlock let result = sqlx::query( r#"INSERT INTO permission_blocks - (id, tenant_id, scope_mode, object_kind, object_type, object_id, effect, conditions) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (id, tenant_id, scope_mode, object_kind, object_type, object_id, group_id, effect, conditions) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO NOTHING"#, ) .bind(block.id) @@ -651,6 +922,7 @@ async fn ensure_permission_block(pool: &PgPool, block: &BootstrapPermissionBlock .bind(&scope.object_kind) .bind(&scope.object_type) .bind(scope.object_id) + .bind(scope.group_id) .bind(&block.effect) .bind(conditions) .execute(pool) @@ -812,6 +1084,13 @@ entities: - kind: shared_key key: a-strong-device-secret +resources: + - id: 99999999-9999-9999-9999-999999999999 + kind: channel + name: temperature + tenant_id: 33333333-3333-3333-3333-333333333333 + owner_id: 22222222-2222-2222-2222-222222222222 + groups: - id: 77777777-7777-7777-7777-777777777777 name: publishers @@ -819,6 +1098,13 @@ groups: members: - 22222222-2222-2222-2222-222222222222 +object_groups: + - id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + name: production-channels + tenant_id: 33333333-3333-3333-3333-333333333333 + resources: + - 99999999-9999-9999-9999-999999999999 + permission_blocks: - id: 44444444-4444-4444-4444-444444444444 scope: @@ -828,6 +1114,15 @@ permission_blocks: object_type: resource:channel actions: [publish, subscribe] effect: allow + - id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb + scope: + mode: group_direct_objects + tenant_id: 33333333-3333-3333-3333-333333333333 + group_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + object_kind: resource + object_type: resource:channel + actions: [read] + effect: allow roles: - id: 55555555-5555-5555-5555-555555555555 @@ -850,9 +1145,16 @@ direct_policies: let cfg = parse(yaml).expect("parse"); assert_eq!(cfg.tenants.len(), 1); assert_eq!(cfg.entities.len(), 1); + assert_eq!(cfg.resources[0].kind, "channel"); assert_eq!(cfg.groups[0].members.len(), 1); + assert_eq!(cfg.object_groups[0].resources.len(), 1); assert_eq!(cfg.permission_blocks[0].scope.mode, ScopeMode::ObjectType); assert_eq!(cfg.permission_blocks[0].effect, Effect::Allow); + assert_eq!( + cfg.permission_blocks[1].scope.mode, + ScopeMode::GroupDirectObjects + ); + assert!(cfg.permission_blocks[1].scope.group_id.is_some()); assert_eq!(cfg.roles[0].permission_blocks.len(), 1); assert_eq!(cfg.role_assignments[0].subject.kind, SubjectKind::Entity); assert_eq!(cfg.direct_policies[0].subject.kind, SubjectKind::Group); @@ -1024,4 +1326,107 @@ tenants: let err = parse(yaml).expect_err("duplicate tenant ids"); assert!(err.to_string().contains("duplicate bootstrap tenant id")); } + + #[test] + fn group_scope_requires_group_id() { + let yaml = r#" +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: group_direct_objects + tenant_id: 33333333-3333-3333-3333-333333333333 + object_kind: resource + actions: [read] +"#; + let err = parse(yaml).expect_err("missing group_id"); + assert!(err.to_string().contains("require tenant_id and group_id")); + } + + #[test] + fn group_object_scope_requires_entity_or_resource_kind() { + let yaml = r#" +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: group_direct_objects + tenant_id: 33333333-3333-3333-3333-333333333333 + group_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + object_kind: tenant + actions: [read] +"#; + let err = parse(yaml).expect_err("bad object_kind"); + assert!(err + .to_string() + .contains("object_kind of 'entity' or 'resource'")); + } + + #[test] + fn group_object_scope_requires_object_type() { + let yaml = r#" +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: group_direct_objects + tenant_id: 33333333-3333-3333-3333-333333333333 + group_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + object_kind: resource + actions: [read] +"#; + let err = parse(yaml).expect_err("missing object_type"); + assert!(err.to_string().contains("require object_type")); + } + + #[test] + fn group_id_rejected_on_non_group_scope() { + let yaml = r#" +permission_blocks: + - id: 44444444-4444-4444-4444-444444444444 + scope: + mode: tenant + tenant_id: 33333333-3333-3333-3333-333333333333 + group_id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + actions: [read] +"#; + let err = parse(yaml).expect_err("group_id on tenant scope"); + assert!(err + .to_string() + .contains("only group_* scopes take a group_id")); + } + + #[test] + fn object_group_with_members_requires_tenant() { + let yaml = r#" +object_groups: + - id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + name: channels + resources: + - 99999999-9999-9999-9999-999999999999 +"#; + let err = parse(yaml).expect_err("members without tenant"); + assert!(err.to_string().contains("has members but no tenant_id")); + } + + #[test] + fn object_group_cannot_be_its_own_parent() { + let yaml = r#" +object_groups: + - id: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + name: channels + tenant_id: 33333333-3333-3333-3333-333333333333 + parent: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +"#; + let err = parse(yaml).expect_err("self parent"); + assert!(err.to_string().contains("cannot be its own parent")); + } + + #[test] + fn resource_requires_kind() { + let yaml = r#" +resources: + - id: 99999999-9999-9999-9999-999999999999 + kind: " " +"#; + let err = parse(yaml).expect_err("empty kind"); + assert!(err.to_string().contains("empty kind")); + } } diff --git a/tests/m25_config_bootstrap.rs b/tests/m25_config_bootstrap.rs index 26d2f094..6d1b3156 100644 --- a/tests/m25_config_bootstrap.rs +++ b/tests/m25_config_bootstrap.rs @@ -9,12 +9,15 @@ mod common; use atom::bootstrap::{ apply, BootstrapConfig, BootstrapCredential, BootstrapDirectPolicy, BootstrapEntity, - BootstrapGroup, BootstrapPermissionBlock, BootstrapRole, BootstrapRoleAssignment, - BootstrapScope, BootstrapSubject, BootstrapTenant, ScopeMode, + BootstrapGroup, BootstrapObjectGroup, BootstrapPermissionBlock, BootstrapResource, + BootstrapRole, BootstrapRoleAssignment, BootstrapScope, BootstrapSubject, BootstrapTenant, + ScopeMode, }; use atom::config::Config; use atom::models::enums::{EntityKind, EntityStatus, SubjectKind, TenantStatus}; +use atom::models::policy::AuthzRequest; use common::pool; +use serde_json::json; use uuid::Uuid; async fn count_active_credentials(pool: &sqlx::PgPool, entity_id: Uuid, kind: &str) -> i64 { @@ -194,6 +197,7 @@ fn rbac_config( object_kind: Some("resource".to_string()), object_type: Some("resource:channel".to_string()), object_id: None, + group_id: None, }, actions: vec!["publish".to_string(), "subscribe".to_string()], effect: Default::default(), @@ -329,6 +333,7 @@ async fn bootstrap_supports_group_subjects_and_direct_policies() { object_kind: None, object_type: None, object_id: None, + group_id: None, }, actions: vec!["read".to_string()], effect: Default::default(), @@ -375,3 +380,147 @@ async fn bootstrap_supports_group_subjects_and_direct_policies() { "device should inherit read via group direct policy" ); } + +#[tokio::test] +#[ignore] +async fn bootstrap_provisions_resources_and_object_group_scoped_grant() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let tenant = Uuid::new_v4(); + let device = Uuid::new_v4(); + let channel = Uuid::new_v4(); + let object_group = Uuid::new_v4(); + let block = Uuid::new_v4(); + let role = Uuid::new_v4(); + + let cfg = BootstrapConfig { + tenants: vec![BootstrapTenant { + id: tenant, + name: format!("bootstrap-tenant-{tenant}"), + alias: None, + tags: vec![], + attributes: None, + status: TenantStatus::Active, + }], + entities: vec![BootstrapEntity { + id: device, + kind: EntityKind::Device, + name: format!("bootstrap-device-{device}"), + alias: None, + status: EntityStatus::Active, + attributes: None, + tenant_id: Some(tenant), + credentials: vec![], + }], + resources: vec![BootstrapResource { + id: channel, + kind: "channel".to_string(), + name: Some("temperature".to_string()), + alias: None, + tenant_id: Some(tenant), + owner_id: Some(device), + attributes: None, + }], + object_groups: vec![BootstrapObjectGroup { + id: object_group, + name: format!("channels-{object_group}"), + tenant_id: Some(tenant), + description: None, + attributes: None, + parent: None, + entities: vec![], + resources: vec![channel], + }], + permission_blocks: vec![BootstrapPermissionBlock { + id: block, + scope: BootstrapScope { + mode: ScopeMode::GroupDirectObjects, + tenant_id: Some(tenant), + object_kind: Some("resource".to_string()), + object_type: Some("resource:channel".to_string()), + object_id: None, + group_id: Some(object_group), + }, + actions: vec!["publish".to_string()], + effect: Default::default(), + conditions: None, + }], + roles: vec![BootstrapRole { + id: role, + name: format!("channel-publisher-{role}"), + tenant_id: Some(tenant), + description: None, + permission_blocks: vec![block], + }], + role_assignments: vec![BootstrapRoleAssignment { + id: Uuid::new_v4(), + tenant_id: Some(tenant), + subject: BootstrapSubject { + kind: SubjectKind::Entity, + id: device, + }, + role_id: role, + }], + ..Default::default() + }; + + // Apply twice for idempotency, then let the PDP prove the whole chain. + apply(&p, &signing_keys, &cfg).await.expect("first apply"); + apply(&p, &signing_keys, &cfg).await.expect("second apply"); + + // Resource + object-group membership landed. + let membership: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM object_group_resources WHERE group_id = $1 AND resource_id = $2", + ) + .bind(object_group) + .bind(channel) + .fetch_one(&p) + .await + .expect("membership"); + assert_eq!(membership, 1); + + // End-to-end: the device can publish on the channel because the group-scoped + // block grants publish on resource members of the object group it belongs to. + let req = AuthzRequest { + subject_id: device, + action: "publish".to_string(), + resource_id: Some(channel), + object_kind: None, + object_id: None, + context: json!({}), + }; + let resp = atom::authz::engine::evaluate_with_ceiling(&p, &req, None) + .await + .expect("evaluate"); + assert!( + resp.allowed, + "device should be allowed to publish on the channel: {}", + resp.reason + ); + + // A different channel outside the object group must NOT be allowed. + let other_channel = Uuid::new_v4(); + sqlx::query( + "INSERT INTO resources (id, kind, name, tenant_id) VALUES ($1, 'channel', 'other', $2)", + ) + .bind(other_channel) + .bind(tenant) + .execute(&p) + .await + .expect("insert other channel"); + let deny_req = AuthzRequest { + subject_id: device, + action: "publish".to_string(), + resource_id: Some(other_channel), + object_kind: None, + object_id: None, + context: json!({}), + }; + let deny = atom::authz::engine::evaluate_with_ceiling(&p, &deny_req, None) + .await + .expect("evaluate other"); + assert!( + !deny.allowed, + "a channel outside the object group must not be granted" + ); +} From f4784a83f404fcdd3c729bd47ebaca2c0e0d5178 Mon Sep 17 00:00:00 2001 From: Arvindh Date: Mon, 3 Aug 2026 16:29:33 +0530 Subject: [PATCH 04/14] feat: bootstrap capabilities and guardrails from the config file Signed-off-by: Arvindh --- migrations/001_initial.sql | 16 +- migrations/004_managed_by.sql | 18 ++ src/authz/repo.rs | 93 ++++++++ src/bootstrap.rs | 249 ++++++++++++++++++- tests/m25_config_bootstrap.rs | 21 +- tests/m26_config_managed_capabilities.rs | 289 +++++++++++++++++++++++ tests/m8_guardrails.rs | 40 ++++ 7 files changed, 709 insertions(+), 17 deletions(-) create mode 100644 migrations/004_managed_by.sql create mode 100644 tests/m26_config_managed_capabilities.rs diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql index 8e171ea9..c53fa960 100644 --- a/migrations/001_initial.sql +++ b/migrations/001_initial.sql @@ -1238,17 +1238,11 @@ FROM actions WHERE actions.name = 'rotate' ON CONFLICT DO NOTHING; -INSERT INTO action_applicability (action_id, object_kind, object_type) -SELECT id, 'resource', 'resource:channel' -FROM actions -WHERE name IN ('publish', 'subscribe') -ON CONFLICT DO NOTHING; - -INSERT INTO action_applicability (action_id, object_kind, object_type) -SELECT id, 'resource', 'resource:rule' -FROM actions -WHERE name = 'execute' -ON CONFLICT DO NOTHING; +-- Product-specific applicability (e.g. `publish` on `resource:channel`, +-- `execute` on `resource:rule`) is provisioned by the deployment's bootstrap +-- config file (see src/bootstrap.rs `capabilities` section), not seeded here. +-- The `publish`, `subscribe`, `execute` actions themselves remain seeded above +-- because they are used by `tenant_admin_bootstrap` in src/tenants/repo.rs. INSERT INTO entities (id, kind, name, status, attributes) VALUES diff --git a/migrations/004_managed_by.sql b/migrations/004_managed_by.sql new file mode 100644 index 00000000..c5407911 --- /dev/null +++ b/migrations/004_managed_by.sql @@ -0,0 +1,18 @@ +-- Marks rows that were provisioned from a declarative bootstrap file (see +-- src/bootstrap.rs) so mutation endpoints can refuse to modify them out of +-- band. NULL = API-managed (default), 'config' = bootstrap-managed. +-- Config-managed rows can still be added to (e.g. extra applicability rows +-- attached to a config-managed capability), but they cannot be updated or +-- deleted via the API — the config file is the source of truth. + +ALTER TABLE actions + ADD COLUMN IF NOT EXISTS managed_by TEXT + CHECK (managed_by IS NULL OR managed_by = 'config'); + +ALTER TABLE action_applicability + ADD COLUMN IF NOT EXISTS managed_by TEXT + CHECK (managed_by IS NULL OR managed_by = 'config'); + +ALTER TABLE action_assignment_rules + ADD COLUMN IF NOT EXISTS managed_by TEXT + CHECK (managed_by IS NULL OR managed_by = 'config'); diff --git a/src/authz/repo.rs b/src/authz/repo.rs index 86923726..325a3016 100644 --- a/src/authz/repo.rs +++ b/src/authz/repo.rs @@ -3458,6 +3458,7 @@ pub async fn delete_action_assignment_rule_with_audit( id: Uuid, transport: &str, ) -> Result { + ensure_not_config_managed_rule(pool, id).await?; let mut tx = pool.begin().await.map_err(db_err)?; let rule = sqlx::query_as::<_, ActionAssignmentRule>( r#"DELETE FROM action_assignment_rules @@ -3495,6 +3496,72 @@ pub async fn delete_action_assignment_rule_with_audit( Ok(rule) } +async fn ensure_not_config_managed_capability( + pool: &PgPool, + capability_id: Uuid, +) -> Result<(), AppError> { + let managed_by: Option> = + sqlx::query_scalar("SELECT managed_by FROM actions WHERE id = $1") + .bind(capability_id) + .fetch_optional(pool) + .await + .map_err(db_err)?; + match managed_by { + None => Err(AppError::not_found(format!( + "capability {capability_id} not found" + ))), + Some(Some(value)) if value == "config" => Err(AppError::conflict( + "capability is managed by the bootstrap config file and cannot be modified via the API", + )), + _ => Ok(()), + } +} + +async fn ensure_not_config_managed_applicability( + pool: &PgPool, + capability_id: Uuid, + object_kind: &str, + object_type: Option<&str>, +) -> Result<(), AppError> { + let managed_by: Option> = sqlx::query_scalar( + r#"SELECT managed_by FROM action_applicability + WHERE action_id = $1 + AND object_kind = $2 + AND object_type IS NOT DISTINCT FROM $3"#, + ) + .bind(capability_id) + .bind(object_kind) + .bind(object_type) + .fetch_optional(pool) + .await + .map_err(db_err)?; + match managed_by { + None => Ok(()), + Some(Some(value)) if value == "config" => Err(AppError::conflict( + "capability applicability is managed by the bootstrap config file and cannot be modified via the API", + )), + _ => Ok(()), + } +} + +async fn ensure_not_config_managed_rule(pool: &PgPool, id: Uuid) -> Result<(), AppError> { + let managed_by: Option> = + sqlx::query_scalar("SELECT managed_by FROM action_assignment_rules WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + .map_err(db_err)?; + match managed_by { + None => Err(AppError::not_found(format!( + "action assignment rule {id} not found" + ))), + Some(Some(value)) if value == "config" => Err(AppError::conflict( + "action assignment rule is managed by the bootstrap config file and cannot be modified via the API", + )), + _ => Ok(()), + } +} + pub async fn add_capability_applicability( pool: &PgPool, capability_id: Uuid, @@ -3607,6 +3674,13 @@ pub async fn remove_capability_applicability_with_audit( object_kind: String, object_type: Option, ) -> Result<(), AppError> { + ensure_not_config_managed_applicability( + pool, + capability_id, + &object_kind, + object_type.as_deref(), + ) + .await?; let mut tx = pool.begin().await.map_err(db_err)?; let result = sqlx::query( r#"DELETE FROM action_applicability @@ -3679,6 +3753,7 @@ pub async fn update_capability_with_audit( id: Uuid, req: crate::models::capability::UpdateCapability, ) -> Result { + ensure_not_config_managed_capability(pool, id).await?; let mut tx = pool.begin().await.map_err(AppError::Database)?; let updated = sqlx::query_as::<_, Capability>( r#"UPDATE actions @@ -3723,6 +3798,23 @@ async fn replace_capability_applicability_in_tx( capability_id: Uuid, applicability: &[CapabilityApplicabilityInput], ) -> Result<(), AppError> { + // Refuse to blow away applicability rows that were declared in the + // bootstrap config, even when the parent capability itself is API-managed. + let has_managed: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM action_applicability + WHERE action_id = $1 AND managed_by = 'config')", + ) + .bind(capability_id) + .fetch_one(&mut **tx) + .await + .map_err(db_err)?; + if has_managed { + return Err(AppError::conflict( + "capability has applicability rows managed by the bootstrap config file; \ + use addCapabilityApplicability / removeCapabilityApplicability instead", + )); + } + sqlx::query("DELETE FROM action_applicability WHERE action_id = $1") .bind(capability_id) .execute(&mut **tx) @@ -3760,6 +3852,7 @@ pub async fn delete_capability_with_audit( actor_id: Option, id: Uuid, ) -> Result<(), AppError> { + ensure_not_config_managed_capability(pool, id).await?; let mut tx = pool.begin().await.map_err(db_err)?; let result = sqlx::query("DELETE FROM actions WHERE id = $1") .bind(id) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 8cdec7c6..af2389d7 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -74,10 +74,15 @@ use crate::config::SigningKeyConfig; use crate::identity; use crate::models::alias::validate_alias_opt; use crate::models::enums::{ - CredentialKind, Effect, EntityKind, EntityStatus, SubjectKind, TenantStatus, + ActionAssignmentDecision, CredentialKind, Effect, EntityKind, EntityStatus, ObjectKind, + SubjectKind, TenantStatus, }; use crate::models::token::CreateSharedKey; +/// Sentinel written to `managed_by` columns for rows provisioned from a +/// bootstrap file. Mutation endpoints refuse to touch rows carrying this tag. +pub const MANAGED_BY_CONFIG: &str = "config"; + /// Root of the bootstrap document. Every section is optional. #[derive(Debug, Clone, Deserialize, PartialEq, Default)] #[serde(deny_unknown_fields)] @@ -100,6 +105,16 @@ pub struct BootstrapConfig { pub role_assignments: Vec, #[serde(default)] pub direct_policies: Vec, + /// Additional action names (capabilities) beyond the built-in vocabulary, + /// e.g. product-specific verbs like `publish` or `alarm.acknowledge`. Each + /// entry may also declare the object kinds/types it applies to. + #[serde(default)] + pub capabilities: Vec, + /// Guardrail rules constraining which entity kinds may perform which + /// actions on which object kinds — the platform-wide "device cannot manage + /// resources" style rails. + #[serde(default)] + pub action_assignment_rules: Vec, } /// A tenant (domain). `None` `tenant_id` on other records means platform scope. @@ -446,6 +461,45 @@ pub struct BootstrapDirectPolicy { pub permission_block_id: Uuid, } +/// A capability (action) to ensure exists. Keyed on `name` (the unique column +/// on `actions`), so re-running the bootstrap file is a no-op. The optional +/// `applicability` block lists object kinds/types this action can target; +/// entries are additive (existing applicability rows are not removed). +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapCapability { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub applicability: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapCapabilityApplicability { + pub object_kind: ObjectKind, + #[serde(default)] + pub object_type: Option, +} + +/// A guardrail rule. Same shape as the `action_assignment_rules` row, minus +/// the auto-generated id. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BootstrapActionAssignmentRule { + #[serde(default)] + pub tenant_id: Option, + pub entity_kind: EntityKind, + pub action_name: String, + pub object_kind: ObjectKind, + #[serde(default)] + pub object_type: Option, + pub decision: ActionAssignmentDecision, + #[serde(default)] + pub is_absolute: bool, +} + impl BootstrapConfig { /// Structural validation performed before touching the database, so a /// malformed file aborts startup with a clear message instead of a partial, @@ -526,6 +580,52 @@ impl BootstrapConfig { bail!("bootstrap role {} has an empty name", role.id); } } + + let mut seen_names = HashSet::new(); + for capability in &self.capabilities { + let name = capability.name.trim(); + if name.is_empty() { + bail!("bootstrap capability has an empty name"); + } + if !seen_names.insert(name.to_string()) { + bail!("duplicate bootstrap capability name {name}"); + } + let mut seen_apps = HashSet::new(); + for app in &capability.applicability { + let key = (app.object_kind, app.object_type.clone()); + if !seen_apps.insert(key) { + bail!( + "capability {name} declares duplicate applicability {}:{}", + app.object_kind.as_str(), + app.object_type.as_deref().unwrap_or("") + ); + } + } + } + + let mut seen_rules = HashSet::new(); + for rule in &self.action_assignment_rules { + let action = rule.action_name.trim(); + if action.is_empty() { + bail!("bootstrap action_assignment_rule has an empty action_name"); + } + let key = ( + rule.tenant_id, + format!("{:?}", rule.entity_kind), + action.to_string(), + rule.object_kind, + rule.object_type.clone(), + ); + if !seen_rules.insert(key) { + bail!( + "duplicate bootstrap action_assignment_rule for {} {} on {}:{}", + format!("{:?}", rule.entity_kind).to_lowercase(), + action, + rule.object_kind.as_str(), + rule.object_type.as_deref().unwrap_or("") + ); + } + } Ok(()) } } @@ -643,6 +743,12 @@ pub async fn apply( for policy in &cfg.direct_policies { ensure_direct_policy(pool, policy).await?; } + for capability in &cfg.capabilities { + ensure_capability(pool, capability).await?; + } + for rule in &cfg.action_assignment_rules { + ensure_action_assignment_rule(pool, rule).await?; + } Ok(()) } @@ -1019,6 +1125,147 @@ async fn ensure_role_assignment(pool: &PgPool, assignment: &BootstrapRoleAssignm Ok(()) } +/// Upsert a capability by name. The row is stamped `managed_by='config'` so +/// mutation endpoints (`update_capability`, `delete_capability`) refuse to +/// touch it out of band. Existing description is overwritten by config — +/// config is authoritative for anything it declares. +async fn ensure_capability(pool: &PgPool, capability: &BootstrapCapability) -> Result<()> { + let name = capability.name.trim().to_string(); + let action_id: Uuid = sqlx::query_scalar( + r#"INSERT INTO actions (name, description, managed_by) + VALUES ($1, $2, $3) + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description, + managed_by = $3, + updated_at = now() + RETURNING id"#, + ) + .bind(&name) + .bind(&capability.description) + .bind(MANAGED_BY_CONFIG) + .fetch_one(pool) + .await + .with_context(|| format!("failed to upsert bootstrap capability {name}"))?; + tracing::info!(action = %name, id = %action_id, "bootstrap: capability upserted"); + + for app in &capability.applicability { + ensure_capability_applicability(pool, action_id, &name, app).await?; + } + Ok(()) +} + +async fn ensure_capability_applicability( + pool: &PgPool, + action_id: Uuid, + action_name: &str, + app: &BootstrapCapabilityApplicability, +) -> Result<()> { + // Two-step upsert: the unique index on this table is functional + // (`COALESCE(object_type, '')`), which makes ON CONFLICT target awkward. + // Insert-then-update is simpler and equally atomic per row. + sqlx::query( + r#"INSERT INTO action_applicability (action_id, object_kind, object_type, managed_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING"#, + ) + .bind(action_id) + .bind(app.object_kind.as_str()) + .bind(&app.object_type) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| { + format!("failed to insert bootstrap applicability for capability {action_name}") + })?; + + sqlx::query( + r#"UPDATE action_applicability + SET managed_by = $4 + WHERE action_id = $1 + AND object_kind = $2 + AND object_type IS NOT DISTINCT FROM $3"#, + ) + .bind(action_id) + .bind(app.object_kind.as_str()) + .bind(&app.object_type) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| { + format!("failed to stamp bootstrap applicability for capability {action_name}") + })?; + Ok(()) +} + +async fn ensure_action_assignment_rule( + pool: &PgPool, + rule: &BootstrapActionAssignmentRule, +) -> Result<()> { + let action = rule.action_name.trim().to_string(); + + // Guardrails reference actions by name — refuse to apply a rule whose + // action doesn't exist, otherwise the DB would happily store a permanently + // dead rule. + let action_exists: bool = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM actions WHERE name = $1)") + .bind(&action) + .fetch_one(pool) + .await + .with_context(|| { + format!("failed to look up action for bootstrap assignment rule {action}") + })?; + if !action_exists { + bail!( + "bootstrap action_assignment_rule references unknown action {action}; \ + declare the capability earlier in the file or via migration" + ); + } + + sqlx::query( + r#"INSERT INTO action_assignment_rules + (tenant_id, entity_kind, action_name, object_kind, object_type, + decision, is_absolute, managed_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT DO NOTHING"#, + ) + .bind(rule.tenant_id) + .bind(&rule.entity_kind) + .bind(&action) + .bind(rule.object_kind.as_str()) + .bind(&rule.object_type) + .bind(&rule.decision) + .bind(rule.is_absolute) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap assignment rule for action {action}"))?; + + // Stamp `managed_by` even if the row already existed (e.g. carried over + // from a fresh-db seed) so the API guard fires on subsequent mutations. + sqlx::query( + r#"UPDATE action_assignment_rules + SET managed_by = $7 + WHERE tenant_id IS NOT DISTINCT FROM $1 + AND entity_kind = $2 + AND action_name = $3 + AND object_kind = $4 + AND object_type IS NOT DISTINCT FROM $5 + AND decision = $6"#, + ) + .bind(rule.tenant_id) + .bind(&rule.entity_kind) + .bind(&action) + .bind(rule.object_kind.as_str()) + .bind(&rule.object_type) + .bind(&rule.decision) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| format!("failed to stamp bootstrap assignment rule for action {action}"))?; + tracing::info!(action = %action, "bootstrap: assignment rule upserted"); + Ok(()) +} + async fn ensure_direct_policy(pool: &PgPool, policy: &BootstrapDirectPolicy) -> Result<()> { let result = sqlx::query( r#"INSERT INTO direct_policies (id, tenant_id, subject_kind, subject_id, permission_block_id) diff --git a/tests/m25_config_bootstrap.rs b/tests/m25_config_bootstrap.rs index 6d1b3156..4b5dec35 100644 --- a/tests/m25_config_bootstrap.rs +++ b/tests/m25_config_bootstrap.rs @@ -8,13 +8,13 @@ mod common; use atom::bootstrap::{ - apply, BootstrapConfig, BootstrapCredential, BootstrapDirectPolicy, BootstrapEntity, - BootstrapGroup, BootstrapObjectGroup, BootstrapPermissionBlock, BootstrapResource, - BootstrapRole, BootstrapRoleAssignment, BootstrapScope, BootstrapSubject, BootstrapTenant, - ScopeMode, + apply, BootstrapCapability, BootstrapCapabilityApplicability, BootstrapConfig, + BootstrapCredential, BootstrapDirectPolicy, BootstrapEntity, BootstrapGroup, + BootstrapObjectGroup, BootstrapPermissionBlock, BootstrapResource, BootstrapRole, + BootstrapRoleAssignment, BootstrapScope, BootstrapSubject, BootstrapTenant, ScopeMode, }; use atom::config::Config; -use atom::models::enums::{EntityKind, EntityStatus, SubjectKind, TenantStatus}; +use atom::models::enums::{EntityKind, EntityStatus, ObjectKind, SubjectKind, TenantStatus}; use atom::models::policy::AuthzRequest; use common::pool; use serde_json::json; @@ -461,6 +461,17 @@ async fn bootstrap_provisions_resources_and_object_group_scoped_grant() { }, role_id: role, }], + // `publish` applicability on `resource:channel` is product-specific + // and no longer seeded by the migration; declare it here so the PDP + // can find the capability for the channel object type. + capabilities: vec![BootstrapCapability { + name: "publish".to_string(), + description: None, + applicability: vec![BootstrapCapabilityApplicability { + object_kind: ObjectKind::Resource, + object_type: Some("resource:channel".to_string()), + }], + }], ..Default::default() }; diff --git a/tests/m26_config_managed_capabilities.rs b/tests/m26_config_managed_capabilities.rs new file mode 100644 index 00000000..a3be425a --- /dev/null +++ b/tests/m26_config_managed_capabilities.rs @@ -0,0 +1,289 @@ +//! Bootstrap-managed capabilities, applicability and assignment rules. +//! +//! Covers the `managed_by='config'` marker written by the bootstrap loader and +//! the API guard that refuses to mutate rows carrying it. +//! +//! Run with: +//! ```bash +//! DATABASE_URL=postgres://... cargo test --test m26_config_managed_capabilities -- --ignored +//! ``` + +mod common; + +use atom::authz::repo; +use atom::bootstrap::{ + apply, BootstrapActionAssignmentRule, BootstrapCapability, BootstrapCapabilityApplicability, + BootstrapConfig, +}; +use atom::config::Config; +use atom::models::capability::{ + CapabilityApplicabilityInput, CreateCapability, UpdateCapability, +}; +use atom::models::enums::{ActionAssignmentDecision, EntityKind, ObjectKind}; +use common::pool; +use uuid::Uuid; + +fn capability_config(name: &str, object_type: &str) -> BootstrapConfig { + BootstrapConfig { + capabilities: vec![BootstrapCapability { + name: name.to_string(), + description: Some(format!("bootstrap {name}")), + applicability: vec![BootstrapCapabilityApplicability { + object_kind: ObjectKind::Resource, + object_type: Some(object_type.to_string()), + }], + }], + ..Default::default() + } +} + +async fn action_id(pool: &sqlx::PgPool, name: &str) -> Uuid { + sqlx::query_scalar("SELECT id FROM actions WHERE name = $1") + .bind(name) + .fetch_one(pool) + .await + .expect("capability id lookup") +} + +async fn managed_by(pool: &sqlx::PgPool, name: &str) -> Option { + sqlx::query_scalar("SELECT managed_by FROM actions WHERE name = $1") + .bind(name) + .fetch_one(pool) + .await + .expect("capability managed_by lookup") +} + +#[tokio::test] +#[ignore] +async fn capability_bootstrap_stamps_managed_by_config() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let name = format!("bootstrap-cap-{}", Uuid::new_v4()); + let object_type = format!("resource:bootstrap-{}", Uuid::new_v4()); + let cfg = capability_config(&name, &object_type); + + apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + + assert_eq!(managed_by(&p, &name).await.as_deref(), Some("config")); + + let app_managed: Option = sqlx::query_scalar( + r#"SELECT ca.managed_by + FROM action_applicability ca + JOIN actions a ON a.id = ca.action_id + WHERE a.name = $1 AND ca.object_type = $2"#, + ) + .bind(&name) + .bind(&object_type) + .fetch_one(&p) + .await + .expect("applicability lookup"); + assert_eq!(app_managed.as_deref(), Some("config")); +} + +#[tokio::test] +#[ignore] +async fn capability_bootstrap_is_idempotent() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let name = format!("bootstrap-cap-{}", Uuid::new_v4()); + let object_type = format!("resource:bootstrap-{}", Uuid::new_v4()); + let cfg = capability_config(&name, &object_type); + + apply(&p, &signing_keys, &cfg).await.expect("first apply"); + apply(&p, &signing_keys, &cfg).await.expect("second apply"); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM actions WHERE name = $1") + .bind(&name) + .fetch_one(&p) + .await + .expect("count capabilities"); + assert_eq!(count, 1); + + let app_count: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) + FROM action_applicability ca + JOIN actions a ON a.id = ca.action_id + WHERE a.name = $1"#, + ) + .bind(&name) + .fetch_one(&p) + .await + .expect("count applicability"); + assert_eq!(app_count, 1); +} + +#[tokio::test] +#[ignore] +async fn api_cannot_update_config_managed_capability() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let name = format!("bootstrap-cap-{}", Uuid::new_v4()); + let object_type = format!("resource:bootstrap-{}", Uuid::new_v4()); + apply(&p, &signing_keys, &capability_config(&name, &object_type)) + .await + .expect("apply bootstrap"); + + let id = action_id(&p, &name).await; + let err = repo::update_capability( + &p, + id, + UpdateCapability { + name: None, + description: Some("hijacked".to_string()), + applicability: None, + }, + ) + .await + .expect_err("update must be rejected"); + assert!( + format!("{err:?}").contains("bootstrap config"), + "unexpected error: {err:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn api_cannot_delete_config_managed_capability() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let name = format!("bootstrap-cap-{}", Uuid::new_v4()); + let object_type = format!("resource:bootstrap-{}", Uuid::new_v4()); + apply(&p, &signing_keys, &capability_config(&name, &object_type)) + .await + .expect("apply bootstrap"); + + let id = action_id(&p, &name).await; + let err = repo::delete_capability(&p, id) + .await + .expect_err("delete must be rejected"); + assert!( + format!("{err:?}").contains("bootstrap config"), + "unexpected error: {err:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn api_can_add_but_not_remove_config_managed_applicability() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let name = format!("bootstrap-cap-{}", Uuid::new_v4()); + let seeded_type = format!("resource:bootstrap-{}", Uuid::new_v4()); + apply(&p, &signing_keys, &capability_config(&name, &seeded_type)) + .await + .expect("apply bootstrap"); + + let id = action_id(&p, &name).await; + + // Adding a new applicability entry alongside a config-managed one is + // allowed — API extensions are additive. + let extra_type = format!("resource:api-{}", Uuid::new_v4()); + repo::add_capability_applicability( + &p, + id, + "resource".to_string(), + Some(extra_type.clone()), + ) + .await + .expect("add applicability"); + + // Removing the config-managed row must be rejected. + let err = repo::remove_capability_applicability( + &p, + id, + "resource".to_string(), + Some(seeded_type.clone()), + ) + .await + .expect_err("removing config-managed applicability must be rejected"); + assert!( + format!("{err:?}").contains("bootstrap config"), + "unexpected error: {err:?}" + ); + + // Removing the API-added row is still allowed. + repo::remove_capability_applicability(&p, id, "resource".to_string(), Some(extra_type)) + .await + .expect("removing api-managed applicability is allowed"); +} + +#[tokio::test] +#[ignore] +async fn assignment_rule_bootstrap_stamps_managed_by_and_guards_delete() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let name = format!("bootstrap-cap-{}", Uuid::new_v4()); + let object_type = format!("resource:bootstrap-{}", Uuid::new_v4()); + + // Bootstrap must declare the capability before the rule that references it. + let cfg = BootstrapConfig { + capabilities: vec![BootstrapCapability { + name: name.clone(), + description: None, + applicability: vec![BootstrapCapabilityApplicability { + object_kind: ObjectKind::Resource, + object_type: Some(object_type.clone()), + }], + }], + action_assignment_rules: vec![BootstrapActionAssignmentRule { + tenant_id: None, + entity_kind: EntityKind::Device, + action_name: name.clone(), + object_kind: ObjectKind::Resource, + object_type: Some(object_type.clone()), + decision: ActionAssignmentDecision::Allow, + is_absolute: false, + }], + ..Default::default() + }; + + apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + + let (rule_id, rule_managed_by): (Uuid, Option) = sqlx::query_as( + r#"SELECT id, managed_by + FROM action_assignment_rules + WHERE entity_kind = 'device' + AND action_name = $1"#, + ) + .bind(&name) + .fetch_one(&p) + .await + .expect("rule lookup"); + assert_eq!(rule_managed_by.as_deref(), Some("config")); + + let err = repo::delete_action_assignment_rule(&p, rule_id) + .await + .expect_err("delete must be rejected"); + assert!( + format!("{err:?}").contains("bootstrap config"), + "unexpected error: {err:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn api_created_capability_stays_api_managed() { + // Sanity check: rows created via the API must not accidentally be stamped + // as config-managed, so their normal edit/delete paths still work. + let p = pool().await; + let name = format!("api-cap-{}", Uuid::new_v4()); + let cap = repo::create_capability( + &p, + CreateCapability { + name: name.clone(), + description: Some("api-created".to_string()), + applicability: Some(vec![CapabilityApplicabilityInput { + object_kind: "resource".to_string(), + object_type: Some(format!("resource:api-{}", Uuid::new_v4())), + }]), + }, + ) + .await + .expect("create capability"); + + assert!(managed_by(&p, &name).await.is_none()); + repo::delete_capability(&p, cap.id) + .await + .expect("delete api-managed capability"); +} + diff --git a/tests/m8_guardrails.rs b/tests/m8_guardrails.rs index 8f6de075..cf945d43 100644 --- a/tests/m8_guardrails.rs +++ b/tests/m8_guardrails.rs @@ -204,6 +204,27 @@ async fn channel_scoped_role_rejects_rule_only_capability() { let publish_id = capability_id(&p, "publish").await; let execute_id = capability_id(&p, "execute").await; + // Applicability for `publish` on `resource:channel` and `execute` on + // `resource:rule` is product-specific and provisioned via the deployment's + // bootstrap config, not seeded by the migration. Add the applicability + // inline so this test is self-contained. + atom::authz::repo::add_capability_applicability( + &p, + publish_id, + "resource".to_string(), + Some("resource:channel".to_string()), + ) + .await + .expect("seed publish applicability"); + atom::authz::repo::add_capability_applicability( + &p, + execute_id, + "resource".to_string(), + Some("resource:rule".to_string()), + ) + .await + .expect("seed execute applicability"); + atom::authz::repo::create_role_with_permission_blocks( &p, CreateRole { @@ -259,6 +280,25 @@ async fn exact_object_permission_block_uses_real_object_type() { let publish_id = capability_id(&p, "publish").await; let execute_id = capability_id(&p, "execute").await; + // See sibling test — product-specific applicability is added inline + // instead of relying on migration-time seeding. + atom::authz::repo::add_capability_applicability( + &p, + publish_id, + "resource".to_string(), + Some("resource:channel".to_string()), + ) + .await + .expect("seed publish applicability"); + atom::authz::repo::add_capability_applicability( + &p, + execute_id, + "resource".to_string(), + Some("resource:rule".to_string()), + ) + .await + .expect("seed execute applicability"); + atom::authz::repo::create_role_with_permission_blocks( &p, CreateRole { From 8de6eb5500482e8f729eda88b99b30d9d65ef673 Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 12:16:02 +0530 Subject: [PATCH 05/14] feat: bootstrap access tokens and protect identity rows from API mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the config-file bootstrap pattern (introduced for capabilities and guardrails) to entities and credentials. Operators can now pre-provision machine access tokens declaratively — pasting the same atom__ string into both the bootstrap YAML and the env file consumed by downstream services — so a stack can come up without a separate token-minting step. New AccessToken variant on BootstrapCredential takes the full token string, parses it via the existing auth::parse_api_key, hashes the secret with the deployment KEK (Argon2 fallback), and inserts the credential row directly. Unscoped, no expiry, keyed on the credential id so re-runs are idempotent. Rows created (or already present and named) via bootstrap are stamped managed_by='config' on entities and credentials (migration 005). Two new guards enforce the invariant across the identity surface: - Entities: update_entity, delete_entity, restore_entity reject with 409 managed by the bootstrap config file. - Credentials: revoke_credential, reveal_shared_key, revoke_access_token, and replace_access_token_permissions return not_found — the API pretends bootstrap-provisioned credentials do not exist, so operator-planted tokens can never surface through introspection. - list_credentials and list_access_tokens filter managed_by IS NULL, so bootstrap credentials do not appear in list responses either. The auth path (auth::auth_from_api_key) does *not* filter on managed_by; runtime authentication with bootstrap tokens still works — a regression guard in m27::bootstrap_access_token_authenticates_at_runtime locks that in. Also patches tests/m13_graphql_authz_admin.rs::channel to seed publish/subscribe -> resource:channel applicability inline, matching the earlier tests/m8_guardrails.rs fix — product-specific applicability is no longer seeded by migration 001, so tests that model a channel must declare it themselves. Signed-off-by: Arvindh --- migrations/005_managed_by_identity.sql | 14 ++ src/auth.rs | 2 +- src/bootstrap.rs | 178 ++++++++++++++++- src/identity/access_tokens.rs | 24 ++- src/identity/repo.rs | 45 +++++ src/identity/service.rs | 19 +- tests/m13_graphql_authz_admin.rs | 13 ++ tests/m27_config_managed_identity.rs | 262 +++++++++++++++++++++++++ 8 files changed, 547 insertions(+), 10 deletions(-) create mode 100644 migrations/005_managed_by_identity.sql create mode 100644 tests/m27_config_managed_identity.rs diff --git a/migrations/005_managed_by_identity.sql b/migrations/005_managed_by_identity.sql new file mode 100644 index 00000000..80905a60 --- /dev/null +++ b/migrations/005_managed_by_identity.sql @@ -0,0 +1,14 @@ +-- Extends the `managed_by` marker from 004 to identity tables. Entities and +-- credentials created from a bootstrap file (`src/bootstrap.rs`) are stamped +-- 'config' so the API refuses to update, delete, revoke, or rotate them. +-- Config-managed credentials are additionally hidden from list/read responses +-- — the API pretends they don't exist so operator-planted tokens can never +-- leak through introspection. + +ALTER TABLE entities + ADD COLUMN IF NOT EXISTS managed_by TEXT + CHECK (managed_by IS NULL OR managed_by = 'config'); + +ALTER TABLE credentials + ADD COLUMN IF NOT EXISTS managed_by TEXT + CHECK (managed_by IS NULL OR managed_by = 'config'); diff --git a/src/auth.rs b/src/auth.rs index 2948eafc..50dfdec9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -136,7 +136,7 @@ pub fn make_api_key(cred_id: Uuid, secret_bytes: &[u8; 32]) -> String { format!("atom_{id_hex}_{secret_hex}") } -fn parse_api_key(key: &str) -> Option<(Uuid, [u8; 32])> { +pub fn parse_api_key(key: &str) -> Option<(Uuid, [u8; 32])> { let rest = key.strip_prefix("atom_")?; if rest.len() != 32 + 1 + 64 { return None; diff --git a/src/bootstrap.rs b/src/bootstrap.rs index af2389d7..d108d10a 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -173,6 +173,21 @@ pub enum BootstrapCredential { #[serde(default)] description: Option, }, + /// A pre-provisioned unscoped access token (`atom__`). + /// The operator generates the token once (e.g. from `openssl rand`) and + /// splices the same value into both this YAML *and* the env file consumed + /// by downstream services — so `docker compose up` needs no round-trip + /// between an atom-bootstrap init container and services waiting on it. + /// The credential row is stamped `managed_by='config'`, so the API refuses + /// to revoke it and hides it from list/read responses. + AccessToken { + /// Full `atom__` token string. + token: String, + /// Human label for the token, surfaced in audit logs. + name: String, + #[serde(default)] + description: Option, + }, } /// A protected resource object (e.g. a `channel`). `kind` is a free-form label. @@ -639,6 +654,7 @@ impl BootstrapEntity { let mut passwords = 0; let mut shared_keys = 0; + let mut access_tokens = HashSet::new(); for cred in &self.credentials { match cred { BootstrapCredential::Password { .. } => passwords += 1, @@ -651,6 +667,26 @@ impl BootstrapEntity { ); } } + BootstrapCredential::AccessToken { token, name, .. } => { + if name.trim().is_empty() { + bail!( + "bootstrap entity {} declares an access token with an empty name", + self.id + ); + } + let (cred_id, _) = crate::auth::parse_api_key(token.trim()).ok_or_else( + || anyhow!( + "bootstrap entity {} declares an access token that is not a valid atom__ string", + self.id + ), + )?; + if !access_tokens.insert(cred_id) { + bail!( + "bootstrap entity {} declares more than one access token with credential id {cred_id}", + self.id + ); + } + } } } if passwords > 1 { @@ -780,7 +816,9 @@ async fn ensure_tenant(pool: &PgPool, tenant: &BootstrapTenant) -> Result<()> { } /// Create the entity if its UUID is not already present. Existing rows are left -/// untouched, so a bootstrap re-run never overwrites runtime edits. +/// untouched, so a bootstrap re-run never overwrites runtime edits. The row is +/// stamped `managed_by='config'` so update/delete/restore endpoints refuse to +/// touch it via the API. async fn ensure_entity(pool: &PgPool, entity: &BootstrapEntity) -> Result<()> { let alias = validate_alias_opt(entity.alias.clone()) .map_err(|e| anyhow!("bootstrap entity {}: {e}", entity.id))?; @@ -790,8 +828,8 @@ async fn ensure_entity(pool: &PgPool, entity: &BootstrapEntity) -> Result<()> { .unwrap_or_else(|| serde_json::json!({})); let result = sqlx::query( - r#"INSERT INTO entities (id, kind, name, alias, tenant_id, status, attributes) - VALUES ($1, $2, $3, $4, $5, $6, $7) + r#"INSERT INTO entities (id, kind, name, alias, tenant_id, status, attributes, managed_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING"#, ) .bind(entity.id) @@ -801,17 +839,29 @@ async fn ensure_entity(pool: &PgPool, entity: &BootstrapEntity) -> Result<()> { .bind(entity.tenant_id) .bind(&entity.status) .bind(attributes) + .bind(MANAGED_BY_CONFIG) .execute(pool) .await .with_context(|| format!("failed to insert bootstrap entity {}", entity.id))?; + // Stamp even when the row already existed, so an entity created earlier via + // the API becomes protected once it appears in the bootstrap file. + sqlx::query("UPDATE entities SET managed_by = $2 WHERE id = $1") + .bind(entity.id) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| format!("failed to stamp bootstrap entity {}", entity.id))?; + log_upsert(result.rows_affected(), "entity", entity.id); Ok(()) } /// Create the credential only if the entity has no active credential of that /// kind yet. Reuses the identity service so hashing, strength validation and -/// shared-key envelope encryption stay identical to the API path. +/// shared-key envelope encryption stay identical to the API path. Every +/// bootstrap-created credential is stamped `managed_by='config'`, which both +/// blocks API mutations and hides the row from list/read responses. async fn ensure_credential( pool: &PgPool, signing_keys: &SigningKeyConfig, @@ -827,6 +877,7 @@ async fn ensure_credential( identity::service::create_password(pool, entity.id, secret) .await .map_err(|e| anyhow!("bootstrap password for entity {}: {e}", entity.id))?; + stamp_managed_credentials(pool, entity.id, CredentialKind::Password).await?; tracing::info!(entity_id = %entity.id, "bootstrap: password credential created"); } BootstrapCredential::SharedKey { key, description } => { @@ -846,9 +897,128 @@ async fn ensure_credential( ) .await .map_err(|e| anyhow!("bootstrap shared key for entity {}: {e}", entity.id))?; + stamp_managed_credentials(pool, entity.id, CredentialKind::SharedKey).await?; tracing::info!(entity_id = %entity.id, "bootstrap: shared key credential created"); } + BootstrapCredential::AccessToken { + token, + name, + description, + } => { + ensure_bootstrap_access_token(pool, signing_keys, entity, token, name, description) + .await?; + } + } + Ok(()) +} + +/// Stamp every active credential of the given kind on the entity as +/// config-managed. Used for Password and SharedKey where the shared identity +/// service creates the row without a `managed_by` opinion. +async fn stamp_managed_credentials( + pool: &PgPool, + entity_id: Uuid, + kind: CredentialKind, +) -> Result<()> { + sqlx::query( + "UPDATE credentials SET managed_by = $3 + WHERE entity_id = $1 AND kind = $2 AND status = 'active'", + ) + .bind(entity_id) + .bind(kind) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| format!("failed to stamp bootstrap credential on entity {entity_id}"))?; + Ok(()) +} + +/// Provision an unscoped access token from operator-supplied material. Parses +/// the full `atom__` string (same format `make_api_key` emits), +/// then inserts the credential row directly — no ceiling, no expiry. Idempotent +/// on the credential id. +async fn ensure_bootstrap_access_token( + pool: &PgPool, + signing_keys: &SigningKeyConfig, + entity: &BootstrapEntity, + token: &str, + name: &str, + description: &Option, +) -> Result<()> { + let name = name.trim(); + if name.is_empty() { + bail!( + "bootstrap access token for entity {} has an empty name", + entity.id + ); } + let (cred_id, secret_bytes) = crate::auth::parse_api_key(token.trim()).ok_or_else(|| { + anyhow!( + "bootstrap access token for entity {} is not a valid atom__ string", + entity.id + ) + })?; + + // Skip if the credential row is already present — bootstrap is idempotent. + let exists: bool = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM credentials WHERE id = $1)") + .bind(cred_id) + .fetch_one(pool) + .await + .with_context(|| { + format!("failed to look up bootstrap access token credential {cred_id}") + })?; + if exists { + // Ensure the marker sticks even if the row was created by an earlier + // bootstrap run against an older Atom without the `managed_by` column. + sqlx::query("UPDATE credentials SET managed_by = $2 WHERE id = $1") + .bind(cred_id) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| format!("failed to stamp existing bootstrap access token {cred_id}"))?; + tracing::info!(entity_id = %entity.id, credential_id = %cred_id, "bootstrap: access token already present, skipped"); + return Ok(()); + } + + // Verifier layout mirrors `identity::access_tokens::create_access_token`: + // keyed HMAC-SHA256 under the deployment KEK when present, argon2 fallback + // otherwise. Same lookup semantics as API-minted tokens. + let (secret_hash, secret_lookup_hash) = match signing_keys.key_encryption_key.as_ref() { + Some(kek) => ( + None::, + Some(crate::crypto::hmac_sha256(kek.expose(), &secret_bytes)), + ), + None => ( + Some( + identity::service::hash_secret(&secret_bytes) + .map_err(|e| anyhow!("bootstrap access token hash for {}: {e}", entity.id))?, + ), + None, + ), + }; + let identifier = token.trim().chars().take(13).collect::(); + let metadata = serde_json::json!({ "name": name, "description": description }); + + sqlx::query( + r#"INSERT INTO credentials + (id, entity_id, kind, identifier, secret_hash, secret_lookup_hash, + scoped, expires_at, metadata, managed_by) + VALUES ($1, $2, $3, $4, $5, $6, false, NULL, $7, $8)"#, + ) + .bind(cred_id) + .bind(entity.id) + .bind(CredentialKind::AccessToken) + .bind(identifier) + .bind(secret_hash) + .bind(secret_lookup_hash) + .bind(metadata) + .bind(MANAGED_BY_CONFIG) + .execute(pool) + .await + .with_context(|| format!("failed to insert bootstrap access token {cred_id}"))?; + + tracing::info!(entity_id = %entity.id, credential_id = %cred_id, "bootstrap: access token credential created"); Ok(()) } diff --git a/src/identity/access_tokens.rs b/src/identity/access_tokens.rs index ef45fcd8..663c0008 100644 --- a/src/identity/access_tokens.rs +++ b/src/identity/access_tokens.rs @@ -179,8 +179,11 @@ pub async fn replace_access_token_permissions_in_tx( "access token supports at most {MAX_ACCESS_TOKEN_PERMISSIONS} permissions" ))); } - let scoped: Option = sqlx::query_scalar( - r#"SELECT scoped FROM credentials + // Bootstrap-managed tokens are invisible to the API. Fold the guard into + // the FOR UPDATE lookup so a config-managed row is treated as not-found — + // the API must never acknowledge that a matching row exists. + let row: Option<(bool, Option)> = sqlx::query_as( + r#"SELECT scoped, managed_by FROM credentials WHERE id = $1 AND entity_id = $2 AND kind = $3 AND status = 'active' FOR UPDATE"#, ) @@ -190,6 +193,11 @@ pub async fn replace_access_token_permissions_in_tx( .fetch_optional(&mut **tx) .await .map_err(db_err)?; + let scoped = match row { + Some((_, Some(mgr))) if mgr == "config" => None, + Some((scoped, _)) => Some(scoped), + None => None, + }; match scoped { None => return Err(AppError::not_found("access token not found")), Some(false) => { @@ -346,11 +354,14 @@ pub async fn list_access_tokens( let limit = params.limit.clamp(1, 100); let offset = params.offset.max(0); + // `managed_by IS NULL` hides bootstrap-provisioned tokens from every + // listing — see `revoke_access_token` for the mutation-side counterpart. let total: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM credentials WHERE entity_id = $1 AND kind = $2 + AND managed_by IS NULL AND ($3::text IS NULL OR status = $3::text)"#, ) .bind(entity_id) @@ -373,6 +384,7 @@ pub async fn list_access_tokens( FROM credentials WHERE entity_id = $1 AND kind = $2 + AND managed_by IS NULL AND ($3::text IS NULL OR status = $3::text) ORDER BY created_at DESC LIMIT $4 OFFSET $5"#, @@ -490,6 +502,11 @@ pub async fn revoke_access_token( /// See [`create_access_token_in_tx`] — the caller owns the commit so the /// revocation and its `credential.revoke` event land atomically. +/// +/// Bootstrap-provisioned tokens (`managed_by='config'`) are invisible to the +/// API: the WHERE clause excludes them so revoke returns not_found rather +/// than acknowledging the row exists. Rotation of those tokens lives in the +/// YAML. pub async fn revoke_access_token_in_tx( tx: &mut Transaction<'_, Postgres>, entity_id: Uuid, @@ -505,7 +522,8 @@ pub async fn revoke_access_token_in_tx( ) WHERE id = $1 AND entity_id = $2 - AND kind = $3"#, + AND kind = $3 + AND managed_by IS NULL"#, ) .bind(cred_id) .bind(entity_id) diff --git a/src/identity/repo.rs b/src/identity/repo.rs index 24a9dfbd..1e7027d3 100644 --- a/src/identity/repo.rs +++ b/src/identity/repo.rs @@ -18,6 +18,48 @@ use crate::{ pub const AUTHENTICATED_USERS_GROUP_ID: Uuid = Uuid::from_u128(5); +/// Refuse mutations against an entity provisioned from the bootstrap config +/// file. Config-managed entities can only be reshaped by editing the YAML and +/// restarting Atom, so all API-facing update/delete/restore paths funnel +/// through this guard. +pub async fn ensure_not_config_managed_entity(pool: &PgPool, id: Uuid) -> Result<(), AppError> { + let managed_by: Option> = + sqlx::query_scalar("SELECT managed_by FROM entities WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await + .map_err(db_err)?; + match managed_by { + None => Err(AppError::not_found(format!("entity {id} not found"))), + Some(Some(value)) if value == "config" => Err(AppError::conflict( + "entity is managed by the bootstrap config file and cannot be modified via the API", + )), + _ => Ok(()), + } +} + +/// Companion for credential mutations. Config-managed credentials are also +/// hidden from list/read responses, so the API pretends they don't exist — +/// both the missing-row and the config-managed cases return `not_found`. +pub async fn ensure_not_config_managed_credential( + pool: &PgPool, + cred_id: Uuid, +) -> Result<(), AppError> { + let managed_by: Option> = + sqlx::query_scalar("SELECT managed_by FROM credentials WHERE id = $1") + .bind(cred_id) + .fetch_optional(pool) + .await + .map_err(db_err)?; + match managed_by { + None => Err(AppError::not_found("credential not found")), + Some(Some(value)) if value == "config" => { + Err(AppError::not_found("credential not found")) + } + _ => Ok(()), + } +} + pub async fn lock_active_entity( tx: &mut Transaction<'_, Postgres>, id: Uuid, @@ -278,6 +320,7 @@ pub async fn update_entity_with_audit( event_name: &str, audit_details: Value, ) -> Result { + ensure_not_config_managed_entity(pool, id).await?; let attributes = req.attributes.clone().map(normalize_attributes); let parent_group_id = attributes .as_ref() @@ -707,6 +750,7 @@ pub async fn delete_entity_with_audit( id: Uuid, deleted_by: Option, ) -> Result<(), AppError> { + ensure_not_config_managed_entity(pool, id).await?; let mut tx = pool.begin().await.map_err(db_err)?; let tenant_id: Option> = @@ -802,6 +846,7 @@ pub async fn restore_entity_with_audit( id: Uuid, restored_by: Option, ) -> Result<(), AppError> { + ensure_not_config_managed_entity(pool, id).await?; let _ = restored_by; let mut tx = pool.begin().await.map_err(db_err)?; diff --git a/src/identity/service.rs b/src/identity/service.rs index efd9f2ce..6c381f81 100644 --- a/src/identity/service.rs +++ b/src/identity/service.rs @@ -2116,6 +2116,10 @@ pub async fn reveal_shared_key( ) -> Result { use sqlx::Row; + // Bootstrap-provisioned shared keys are invisible to the API. Return + // not_found so a caller cannot even confirm the credential exists. + super::repo::ensure_not_config_managed_credential(pool, credential_id).await?; + let row = sqlx::query( r#"SELECT c.expires_at, c.status, @@ -2249,6 +2253,10 @@ pub async fn revoke_credential( /// See [`create_password_in_tx`] — the caller owns the commit so the revocation /// and its domain event land atomically. +/// +/// Config-managed credentials (`managed_by='config'`) are hidden from the API: +/// the WHERE clause below excludes them so revoke returns not_found rather +/// than acknowledging the row exists. pub async fn revoke_credential_in_tx( tx: &mut Transaction<'_, Postgres>, entity_id: Uuid, @@ -2266,7 +2274,7 @@ pub async fn revoke_credential_in_tx( 'revoked_at', now(), 'revocation_reason', 'manual' ) - WHERE id = $1 AND entity_id = $2"#, + WHERE id = $1 AND entity_id = $2 AND managed_by IS NULL"#, ) .bind(cred_id) .bind(entity_id) @@ -2285,8 +2293,15 @@ pub async fn list_credentials( ) -> Result, AppError> { use sqlx::Row; + // `managed_by IS NULL` hides bootstrap-provisioned credentials: they must + // not surface through introspection so the operator's declared secrets + // never leak through a runtime API response. let rows = sqlx::query( - "SELECT id, kind, identifier, status, expires_at, created_at FROM credentials WHERE entity_id = $1 ORDER BY created_at DESC", + "SELECT id, kind, identifier, status, expires_at, created_at + FROM credentials + WHERE entity_id = $1 + AND managed_by IS NULL + ORDER BY created_at DESC", ) .bind(entity_id) .fetch_all(pool) diff --git a/tests/m13_graphql_authz_admin.rs b/tests/m13_graphql_authz_admin.rs index 8c852d30..73e4cd4c 100644 --- a/tests/m13_graphql_authz_admin.rs +++ b/tests/m13_graphql_authz_admin.rs @@ -109,6 +109,19 @@ async fn channel(pool: &PgPool) -> Uuid { .execute(pool) .await .expect("insert channel"); + // `publish`/`subscribe` applicability on `resource:channel` is now + // product-specific and provisioned via the deployment bootstrap YAML; + // tests that model a channel need to declare it inline so the guardrail + // check on `createPermissionBlock` passes. + sqlx::query( + r#"INSERT INTO action_applicability (action_id, object_kind, object_type) + SELECT id, 'resource', 'resource:channel' + FROM actions WHERE name IN ('publish', 'subscribe') + ON CONFLICT DO NOTHING"#, + ) + .execute(pool) + .await + .expect("seed channel applicability"); id } diff --git a/tests/m27_config_managed_identity.rs b/tests/m27_config_managed_identity.rs new file mode 100644 index 00000000..377d343d --- /dev/null +++ b/tests/m27_config_managed_identity.rs @@ -0,0 +1,262 @@ +//! Bootstrap-managed entities, credentials and access tokens. +//! +//! Covers migration 005: entities and credentials created from the bootstrap +//! YAML are stamped `managed_by='config'`, blocking API mutation and hiding +//! credentials from list/read responses. +//! +//! Run with: +//! ```bash +//! DATABASE_URL=postgres://... cargo test --test m27_config_managed_identity -- --ignored +//! ``` + +mod common; + +use atom::auth::make_api_key; +use atom::bootstrap::{ + apply, BootstrapConfig, BootstrapCredential, BootstrapEntity, +}; +use atom::config::Config; +use atom::identity::{access_tokens, repo, service}; +use atom::models::entity::UpdateEntity; +use atom::models::enums::{EntityKind, EntityStatus}; +use common::pool; +use uuid::Uuid; + +fn service_entity(id: Uuid, credentials: Vec) -> BootstrapConfig { + BootstrapConfig { + entities: vec![BootstrapEntity { + id, + kind: EntityKind::Service, + name: format!("cfg-service-{id}"), + alias: None, + status: EntityStatus::Active, + attributes: None, + tenant_id: None, + credentials, + }], + ..Default::default() + } +} + +async fn managed_by_entity(pool: &sqlx::PgPool, id: Uuid) -> Option { + sqlx::query_scalar("SELECT managed_by FROM entities WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .expect("entity managed_by lookup") +} + +async fn managed_by_credential(pool: &sqlx::PgPool, id: Uuid) -> Option { + sqlx::query_scalar("SELECT managed_by FROM credentials WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await + .expect("credential managed_by lookup") +} + +fn bootstrap_token() -> (Uuid, String) { + let cred_id = Uuid::new_v4(); + let mut secret = [0u8; 32]; + for (i, b) in secret.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(37).wrapping_add(1); + } + (cred_id, make_api_key(cred_id, &secret)) +} + +#[tokio::test] +#[ignore] +async fn bootstrap_entity_is_stamped_and_rejects_api_mutations() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let entity_id = Uuid::new_v4(); + let cfg = service_entity(entity_id, vec![]); + + apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + + assert_eq!(managed_by_entity(&p, entity_id).await.as_deref(), Some("config")); + + let err = repo::update_entity( + &p, + entity_id, + UpdateEntity { + name: Some("hijacked".to_string()), + kind: None, + alias: None, + tenant_id: None, + profile_id: None, + profile_version_id: None, + status: None, + attributes: None, + }, + ) + .await + .expect_err("update must be rejected"); + assert!( + format!("{err:?}").contains("bootstrap config"), + "unexpected: {err:?}" + ); + + let err = repo::delete_entity(&p, entity_id, None) + .await + .expect_err("delete must be rejected"); + assert!( + format!("{err:?}").contains("bootstrap config"), + "unexpected: {err:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn bootstrap_access_token_is_hidden_and_rejects_revoke() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let entity_id = Uuid::new_v4(); + let (cred_id, token) = bootstrap_token(); + let cfg = service_entity( + entity_id, + vec![BootstrapCredential::AccessToken { + token: token.clone(), + name: "journal".to_string(), + description: Some("bootstrap".to_string()), + }], + ); + + apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + + // The credential row exists and is stamped. + assert_eq!( + managed_by_credential(&p, cred_id).await.as_deref(), + Some("config") + ); + + // list_credentials must NOT surface it. + let creds = service::list_credentials(&p, entity_id) + .await + .expect("list"); + assert!( + creds.iter().all(|c| c.id != cred_id), + "bootstrap-managed credential leaked to list_credentials" + ); + + // list_access_tokens must NOT surface it either. + let (tokens, total) = access_tokens::list_access_tokens( + &p, + entity_id, + access_tokens::ListAccessTokens { + status: None, + limit: 100, + offset: 0, + }, + ) + .await + .expect("list access tokens"); + assert_eq!(total, 0, "bootstrap tokens should not appear in count"); + assert!( + tokens.is_empty(), + "bootstrap tokens should not appear in list" + ); + + // Revoke must return not_found — the API pretends the row does not exist. + let err = access_tokens::revoke_access_token(&p, entity_id, cred_id) + .await + .expect_err("revoke must be rejected"); + assert!( + format!("{err:?}").contains("not found") || format!("{err:?}").contains("NotFound"), + "unexpected: {err:?}" + ); +} + +#[tokio::test] +#[ignore] +async fn bootstrap_access_token_is_idempotent() { + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let entity_id = Uuid::new_v4(); + let (cred_id, token) = bootstrap_token(); + let cfg = service_entity( + entity_id, + vec![BootstrapCredential::AccessToken { + token, + name: "journal".to_string(), + description: None, + }], + ); + + apply(&p, &signing_keys, &cfg).await.expect("first apply"); + apply(&p, &signing_keys, &cfg).await.expect("second apply"); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credentials WHERE id = $1") + .bind(cred_id) + .fetch_one(&p) + .await + .expect("count credential"); + assert_eq!(count, 1); +} + +#[tokio::test] +#[ignore] +async fn bootstrap_access_token_authenticates_at_runtime() { + // The whole point of bootstrap tokens is that services authenticate with + // them at runtime — so the managed_by hide must NOT reach the auth path. + // Verify the auth lookup ignores managed_by by asking the same query the + // auth path runs and confirming the row is visible for authentication. + let p = pool().await; + let signing_keys = Config::for_tests().signing_keys; + let entity_id = Uuid::new_v4(); + let (cred_id, _token) = bootstrap_token(); + let cfg = service_entity( + entity_id, + vec![BootstrapCredential::AccessToken { + token: _token, + name: "journal".to_string(), + description: None, + }], + ); + + apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + + let entity_ok: Option = sqlx::query_scalar( + r#"SELECT c.entity_id + FROM credentials c + JOIN entities e ON e.id = c.entity_id + WHERE c.id = $1 AND c.kind = 'access_token' AND c.status = 'active' + AND e.deleted_at IS NULL"#, + ) + .bind(cred_id) + .fetch_optional(&p) + .await + .expect("auth-path lookup"); + assert_eq!(entity_ok, Some(entity_id)); +} + +#[tokio::test] +#[ignore] +async fn api_created_entity_and_credential_are_not_stamped() { + // Sanity check: only bootstrap-planted rows carry the flag; normal API + // creations remain fully mutable and visible. + let p = pool().await; + + let entity_id = Uuid::new_v4(); + sqlx::query("INSERT INTO entities (id, kind, name, status) VALUES ($1, 'service', $2, 'active')") + .bind(entity_id) + .bind(format!("runtime-{entity_id}")) + .execute(&p) + .await + .expect("insert entity"); + assert!(managed_by_entity(&p, entity_id).await.is_none()); + + let cred_id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO credentials (id, entity_id, kind, secret_hash) VALUES ($1, $2, 'password', 'x')", + ) + .bind(cred_id) + .bind(entity_id) + .execute(&p) + .await + .expect("insert credential"); + assert!(managed_by_credential(&p, cred_id).await.is_none()); + + // The API mutation guard lets these through. + let creds = service::list_credentials(&p, entity_id).await.expect("list"); + assert!(creds.iter().any(|c| c.id == cred_id)); +} From 6caf5b037ab3fe8122d21eff2d245572b5ee101b Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 13:30:03 +0530 Subject: [PATCH 06/14] fix: move product-specific applicability strip into its own migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse the in-place edit to migrations/001_initial.sql (which stripped the seeded publish/subscribe -> resource:channel and execute -> resource:rule applicability rows) and land the same deletion in a new migration 006. Sqlx checksums every applied migration and refuses to run against a database whose recorded checksum for an already-applied migration does not match the file. So the earlier in-place edit broke the upgrade path: any existing atom deployment would crash-loop on the next image pull with "migration 1 was previously applied but has been modified". Fresh deployments were fine, which is how the earlier PR passed CI. Migration 006 does the deletion cleanly for both cases: - Existing deployments: 001 checksum unchanged, no crash; 006 removes the two rows. - Fresh deployments: 001 seeds the rows, 006 deletes them a few statements later — a few wasted inserts, no functional change. Downstream products still supply their own applicability via the bootstrap YAML `capabilities` block (magistrala already does this). Signed-off-by: Arvindh --- migrations/001_initial.sql | 16 +++++++++++----- ...06_strip_product_specific_applicability.sql | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 migrations/006_strip_product_specific_applicability.sql diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql index c53fa960..8e171ea9 100644 --- a/migrations/001_initial.sql +++ b/migrations/001_initial.sql @@ -1238,11 +1238,17 @@ FROM actions WHERE actions.name = 'rotate' ON CONFLICT DO NOTHING; --- Product-specific applicability (e.g. `publish` on `resource:channel`, --- `execute` on `resource:rule`) is provisioned by the deployment's bootstrap --- config file (see src/bootstrap.rs `capabilities` section), not seeded here. --- The `publish`, `subscribe`, `execute` actions themselves remain seeded above --- because they are used by `tenant_admin_bootstrap` in src/tenants/repo.rs. +INSERT INTO action_applicability (action_id, object_kind, object_type) +SELECT id, 'resource', 'resource:channel' +FROM actions +WHERE name IN ('publish', 'subscribe') +ON CONFLICT DO NOTHING; + +INSERT INTO action_applicability (action_id, object_kind, object_type) +SELECT id, 'resource', 'resource:rule' +FROM actions +WHERE name = 'execute' +ON CONFLICT DO NOTHING; INSERT INTO entities (id, kind, name, status, attributes) VALUES diff --git a/migrations/006_strip_product_specific_applicability.sql b/migrations/006_strip_product_specific_applicability.sql new file mode 100644 index 00000000..e2126805 --- /dev/null +++ b/migrations/006_strip_product_specific_applicability.sql @@ -0,0 +1,18 @@ +-- Remove the two product-specific applicability rows migration 001 originally +-- seeded for magistrala (`publish`/`subscribe` on `resource:channel`, +-- `execute` on `resource:rule`). These are IoT-flavoured defaults that don't +-- belong in a generic authorization service — each product ships its own +-- vocabulary via the bootstrap config file (see src/bootstrap.rs +-- `capabilities` section, and the companion PR in magistrala). +-- +-- Kept as a separate migration rather than editing 001 in place: modifying an +-- already-applied migration changes its checksum and makes sqlx refuse to +-- start against any existing deployment. This delta migration is safe both +-- for fresh installs (the rows are seeded by 001 and then removed here — a +-- few wasted inserts, no functional change) and for upgrades. + +DELETE FROM action_applicability +WHERE (object_kind, object_type) IN ( + ('resource', 'resource:channel'), + ('resource', 'resource:rule') +); From 5b2abd52cc422ed7e153e350210a015975405eea Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 14:24:10 +0530 Subject: [PATCH 07/14] fix ci Signed-off-by: Arvindh --- src/bootstrap.rs | 4 ++- src/identity/repo.rs | 4 +-- tests/m26_config_managed_capabilities.rs | 24 ++++++--------- tests/m27_config_managed_identity.rs | 39 +++++++++++++++--------- 4 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index d108d10a..45d77258 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -976,7 +976,9 @@ async fn ensure_bootstrap_access_token( .bind(MANAGED_BY_CONFIG) .execute(pool) .await - .with_context(|| format!("failed to stamp existing bootstrap access token {cred_id}"))?; + .with_context(|| { + format!("failed to stamp existing bootstrap access token {cred_id}") + })?; tracing::info!(entity_id = %entity.id, credential_id = %cred_id, "bootstrap: access token already present, skipped"); return Ok(()); } diff --git a/src/identity/repo.rs b/src/identity/repo.rs index 1e7027d3..fc1e4cd9 100644 --- a/src/identity/repo.rs +++ b/src/identity/repo.rs @@ -53,9 +53,7 @@ pub async fn ensure_not_config_managed_credential( .map_err(db_err)?; match managed_by { None => Err(AppError::not_found("credential not found")), - Some(Some(value)) if value == "config" => { - Err(AppError::not_found("credential not found")) - } + Some(Some(value)) if value == "config" => Err(AppError::not_found("credential not found")), _ => Ok(()), } } diff --git a/tests/m26_config_managed_capabilities.rs b/tests/m26_config_managed_capabilities.rs index a3be425a..6b9dec8e 100644 --- a/tests/m26_config_managed_capabilities.rs +++ b/tests/m26_config_managed_capabilities.rs @@ -16,9 +16,7 @@ use atom::bootstrap::{ BootstrapConfig, }; use atom::config::Config; -use atom::models::capability::{ - CapabilityApplicabilityInput, CreateCapability, UpdateCapability, -}; +use atom::models::capability::{CapabilityApplicabilityInput, CreateCapability, UpdateCapability}; use atom::models::enums::{ActionAssignmentDecision, EntityKind, ObjectKind}; use common::pool; use uuid::Uuid; @@ -62,7 +60,9 @@ async fn capability_bootstrap_stamps_managed_by_config() { let object_type = format!("resource:bootstrap-{}", Uuid::new_v4()); let cfg = capability_config(&name, &object_type); - apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + apply(&p, &signing_keys, &cfg) + .await + .expect("apply bootstrap"); assert_eq!(managed_by(&p, &name).await.as_deref(), Some("config")); @@ -178,14 +178,9 @@ async fn api_can_add_but_not_remove_config_managed_applicability() { // Adding a new applicability entry alongside a config-managed one is // allowed — API extensions are additive. let extra_type = format!("resource:api-{}", Uuid::new_v4()); - repo::add_capability_applicability( - &p, - id, - "resource".to_string(), - Some(extra_type.clone()), - ) - .await - .expect("add applicability"); + repo::add_capability_applicability(&p, id, "resource".to_string(), Some(extra_type.clone())) + .await + .expect("add applicability"); // Removing the config-managed row must be rejected. let err = repo::remove_capability_applicability( @@ -237,7 +232,9 @@ async fn assignment_rule_bootstrap_stamps_managed_by_and_guards_delete() { ..Default::default() }; - apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + apply(&p, &signing_keys, &cfg) + .await + .expect("apply bootstrap"); let (rule_id, rule_managed_by): (Uuid, Option) = sqlx::query_as( r#"SELECT id, managed_by @@ -286,4 +283,3 @@ async fn api_created_capability_stays_api_managed() { .await .expect("delete api-managed capability"); } - diff --git a/tests/m27_config_managed_identity.rs b/tests/m27_config_managed_identity.rs index 377d343d..c20a5c91 100644 --- a/tests/m27_config_managed_identity.rs +++ b/tests/m27_config_managed_identity.rs @@ -12,9 +12,7 @@ mod common; use atom::auth::make_api_key; -use atom::bootstrap::{ - apply, BootstrapConfig, BootstrapCredential, BootstrapEntity, -}; +use atom::bootstrap::{apply, BootstrapConfig, BootstrapCredential, BootstrapEntity}; use atom::config::Config; use atom::identity::{access_tokens, repo, service}; use atom::models::entity::UpdateEntity; @@ -71,9 +69,14 @@ async fn bootstrap_entity_is_stamped_and_rejects_api_mutations() { let entity_id = Uuid::new_v4(); let cfg = service_entity(entity_id, vec![]); - apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + apply(&p, &signing_keys, &cfg) + .await + .expect("apply bootstrap"); - assert_eq!(managed_by_entity(&p, entity_id).await.as_deref(), Some("config")); + assert_eq!( + managed_by_entity(&p, entity_id).await.as_deref(), + Some("config") + ); let err = repo::update_entity( &p, @@ -121,7 +124,9 @@ async fn bootstrap_access_token_is_hidden_and_rejects_revoke() { }], ); - apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + apply(&p, &signing_keys, &cfg) + .await + .expect("apply bootstrap"); // The credential row exists and is stamped. assert_eq!( @@ -213,7 +218,9 @@ async fn bootstrap_access_token_authenticates_at_runtime() { }], ); - apply(&p, &signing_keys, &cfg).await.expect("apply bootstrap"); + apply(&p, &signing_keys, &cfg) + .await + .expect("apply bootstrap"); let entity_ok: Option = sqlx::query_scalar( r#"SELECT c.entity_id @@ -237,12 +244,14 @@ async fn api_created_entity_and_credential_are_not_stamped() { let p = pool().await; let entity_id = Uuid::new_v4(); - sqlx::query("INSERT INTO entities (id, kind, name, status) VALUES ($1, 'service', $2, 'active')") - .bind(entity_id) - .bind(format!("runtime-{entity_id}")) - .execute(&p) - .await - .expect("insert entity"); + sqlx::query( + "INSERT INTO entities (id, kind, name, status) VALUES ($1, 'service', $2, 'active')", + ) + .bind(entity_id) + .bind(format!("runtime-{entity_id}")) + .execute(&p) + .await + .expect("insert entity"); assert!(managed_by_entity(&p, entity_id).await.is_none()); let cred_id = Uuid::new_v4(); @@ -257,6 +266,8 @@ async fn api_created_entity_and_credential_are_not_stamped() { assert!(managed_by_credential(&p, cred_id).await.is_none()); // The API mutation guard lets these through. - let creds = service::list_credentials(&p, entity_id).await.expect("list"); + let creds = service::list_credentials(&p, entity_id) + .await + .expect("list"); assert!(creds.iter().any(|c| c.id == cred_id)); } From 60ad51eff1be02dc20018a6f7f660516a7d1ace7 Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 14:31:00 +0530 Subject: [PATCH 08/14] fix(bootstrap): drop needless borrow on Copy ActionAssignmentDecision clippy::needless_borrows_for_generic_args fires on `.bind(&rule.decision)` in both the insert and stamp queries of `ensure_action_assignment_rule`. `ActionAssignmentDecision` derives `Copy`, so `.bind` takes it by value. Local rustc didn't flag it (older toolchain / older clippy); the ubuntu runner's newer clippy did, breaking `cargo clippy -- -D warnings` on the PR CI. Signed-off-by: Arvindh --- src/bootstrap.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 45d77258..a4edb123 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -1405,7 +1405,7 @@ async fn ensure_action_assignment_rule( .bind(&action) .bind(rule.object_kind.as_str()) .bind(&rule.object_type) - .bind(&rule.decision) + .bind(rule.decision) .bind(rule.is_absolute) .bind(MANAGED_BY_CONFIG) .execute(pool) @@ -1429,7 +1429,7 @@ async fn ensure_action_assignment_rule( .bind(&action) .bind(rule.object_kind.as_str()) .bind(&rule.object_type) - .bind(&rule.decision) + .bind(rule.decision) .bind(MANAGED_BY_CONFIG) .execute(pool) .await From 3312eb09364f45d95343767e1a31dbea45f73208 Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 14:38:54 +0530 Subject: [PATCH 09/14] fix(migrations): renumber to 005/006/007, main took slot 004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main landed `004_event_outbox.sql` (commit 9efa45f, PR #41) while this branch's `004_managed_by.sql` also holds version 4. sqlx tracks migrations by version number as the primary key of `_sqlx_migrations`, so applying both against the same database fails with: duplicate key value violates unique constraint "_sqlx_migrations_pkey" Key (version)=(4) already exists. That's the failure the CI test job hit — every DB-backed unit test in the compiled binary tried to run migrations on a fresh Postgres and crashed at 004. Renumber this branch's three migrations one slot up so they sit after the newly-added event_outbox migration: 004_managed_by.sql -> 005_managed_by.sql 005_managed_by_identity.sql -> 006_managed_by_identity.sql 006_strip_product_specific_applicability.sql -> 007_... The renumbering is safe because these migrations are only referenced by their file paths (never by version number in code), and only ever ran against fresh test databases so far (CI uses a fresh Postgres service container per run). No production deployment ever recorded version 4 or 5 as "managed_by" — event_outbox will now claim 4 cleanly. Also updated one doc comment in m27 that referred to "migration 005". Signed-off-by: Arvindh --- migrations/{004_managed_by.sql => 005_managed_by.sql} | 0 ...{005_managed_by_identity.sql => 006_managed_by_identity.sql} | 2 +- ...ability.sql => 007_strip_product_specific_applicability.sql} | 0 tests/m27_config_managed_identity.rs | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename migrations/{004_managed_by.sql => 005_managed_by.sql} (100%) rename migrations/{005_managed_by_identity.sql => 006_managed_by_identity.sql} (90%) rename migrations/{006_strip_product_specific_applicability.sql => 007_strip_product_specific_applicability.sql} (100%) diff --git a/migrations/004_managed_by.sql b/migrations/005_managed_by.sql similarity index 100% rename from migrations/004_managed_by.sql rename to migrations/005_managed_by.sql diff --git a/migrations/005_managed_by_identity.sql b/migrations/006_managed_by_identity.sql similarity index 90% rename from migrations/005_managed_by_identity.sql rename to migrations/006_managed_by_identity.sql index 80905a60..13cdefbc 100644 --- a/migrations/005_managed_by_identity.sql +++ b/migrations/006_managed_by_identity.sql @@ -1,4 +1,4 @@ --- Extends the `managed_by` marker from 004 to identity tables. Entities and +-- Extends the `managed_by` marker from 005 to identity tables. Entities and -- credentials created from a bootstrap file (`src/bootstrap.rs`) are stamped -- 'config' so the API refuses to update, delete, revoke, or rotate them. -- Config-managed credentials are additionally hidden from list/read responses diff --git a/migrations/006_strip_product_specific_applicability.sql b/migrations/007_strip_product_specific_applicability.sql similarity index 100% rename from migrations/006_strip_product_specific_applicability.sql rename to migrations/007_strip_product_specific_applicability.sql diff --git a/tests/m27_config_managed_identity.rs b/tests/m27_config_managed_identity.rs index c20a5c91..f2b7db64 100644 --- a/tests/m27_config_managed_identity.rs +++ b/tests/m27_config_managed_identity.rs @@ -1,6 +1,6 @@ //! Bootstrap-managed entities, credentials and access tokens. //! -//! Covers migration 005: entities and credentials created from the bootstrap +//! Covers migration 006: entities and credentials created from the bootstrap //! YAML are stamped `managed_by='config'`, blocking API mutation and hiding //! credentials from list/read responses. //! From 8a0a7a76f6b2436fd7451b9f546927557b514bee Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 14:49:17 +0530 Subject: [PATCH 10/14] fix(test): seed publish->resource:channel applicability inline in m3 platform-resource test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle-deny sibling tests in this file all pass because a frozen / inactive / deleted tenant short-circuits the PDP before applicability is checked. `platform_resource_unaffected_by_tenant_lifecycle` has no tenant to short-circuit on — the request must reach the applicability check — and migration 007 removed the seeded `publish -> resource:channel` applicability row that this test relied on. Add the applicability inline (mirrors the m8/m13 fixes), so the test stays product-agnostic without regressing. Signed-off-by: Arvindh --- tests/m3_lifecycle.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/m3_lifecycle.rs b/tests/m3_lifecycle.rs index d8ca38f5..93cbe6d9 100644 --- a/tests/m3_lifecycle.rs +++ b/tests/m3_lifecycle.rs @@ -189,6 +189,22 @@ async fn platform_resource_unaffected_by_tenant_lifecycle() { // A resource with tenant_id = NULL (platform-scoped) must NOT be denied // by the lifecycle check. let p = pool().await; + // `publish` applicability on `resource:channel` is product-specific and + // migration 007 removes the seeded row; declare it inline so the + // capability lookup succeeds and the request reaches the lifecycle path. + // Sibling lifecycle-deny tests pass without this because tenant-frozen + // deny short-circuits before applicability is checked; this platform test + // has no tenant to short-circuit on. + sqlx::query( + r#"INSERT INTO action_applicability (action_id, object_kind, object_type) + SELECT id, 'resource', 'resource:channel' + FROM actions WHERE name = 'publish' + ON CONFLICT DO NOTHING"#, + ) + .execute(&p) + .await + .expect("seed publish applicability"); + let id = Uuid::new_v4(); sqlx::query("INSERT INTO resources (id, kind, name) VALUES ($1, 'channel', $2)") .bind(id) From cec19b9c914296a2418171bc850a6f3f0f5a83b4 Mon Sep 17 00:00:00 2001 From: Arvindh Date: Tue, 4 Aug 2026 16:54:59 +0530 Subject: [PATCH 11/14] feat(ui + api): surface managed_by='config' rows read-only instead of hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously credentials provisioned from the bootstrap YAML were hidden entirely from the API — list_credentials / list_access_tokens filtered them out and revoke returned 404. That was over-cautious: list responses only carry metadata (id, kind, identifier, status, timestamps) with no secret material, so hiding those rows just left operators unable to see what tokens their services were using. Change credentials to match how entities, capabilities, applicability and guardrails already behave: - list_credentials / list_access_tokens no longer filter managed_by; every entry now carries `managed_by: Option`. - revoke_credential / revoke_access_token / replace_access_token_permissions return 409 conflict ("managed by the bootstrap config file") instead of 404 not_found. - reveal_shared_key stays at 404: that is the one endpoint that returns the plaintext key material, and the operator's declared key must not leak through introspection. Also plumb `managed_by` through the GraphQL response types so the UI can render it: Entity, Capability, CapabilityApplicability, CapabilityApplicabilityEntry, ActionAssignmentRule, Credential, AccessToken all gained a `managedBy: String | null` field. Model structs carry `#[sqlx(default)]` on managed_by so RETURNING clauses and other SELECTs that omit the column still hydrate cleanly; the list/get read paths that surface the flag to the UI explicitly include it in their SQL. Follow-up: extend the marker to roles, permission blocks, and other bootstrap-created rows when the bootstrap layer starts stamping them (out of scope here). --- UI --- Point the admin UI at the new flag: - New `components/crud/managed-by-badge.tsx` renders a small "Config" badge (with lock icon) when a row has `managedBy === 'config'`, and exports a shared `isConfigManaged` predicate + tooltip string. - `components/crud/table/utils.ts` gains `isConfigManagedRow` mirroring the existing `isDeletedRow` pattern. - `TableRowActions` in `components/crud/crud-table.tsx` returns Inspect- only for config-managed rows, hiding every mutation button, exactly the way deleted rows already show Inspect + Restore/Purge only. - Added `managedBy` to the six GraphQL queries the UI runs: entities, actions, action-applicability, action-assignment-rules, credentials, access tokens. - Added a `managedBy` column to the four crud-table resources; badge is the null-safe renderer, so API-managed rows show nothing at all. - In the entity-detail credentials sub-panel, config-managed rows show the badge and hide their revoke/reveal/renew/download buttons. --- Tests --- `tests/m27_config_managed_identity.rs::bootstrap_access_token_is_visible_read_only` flipped from asserting "hidden + not_found" to "visible + 409 conflict". The auth-path regression test still verifies bootstrap tokens authenticate at runtime. Verified locally: cargo check --tests + cargo test -- --ignored (except the AMQP-broker tests in m27_live_amqp_delivery which CI already skips), pnpm tsc --noEmit, pnpm biome check on the touched UI files. Signed-off-by: Arvindh --- app/components/crud/crud-table.tsx | 16 +++++ app/components/crud/managed-by-badge.tsx | 41 ++++++++++++ app/components/crud/table/cell-rendering.tsx | 4 ++ app/components/crud/table/utils.ts | 10 +++ .../entities/entity-credentials.tsx | 13 +++- app/lib/crud/resources.ts | 12 ++-- src/authz/repo.rs | 16 ++--- src/graphql/types/mod.rs | 59 ++++++++++++++++++ src/identity/access_tokens.rs | 62 ++++++++++++------- src/identity/repo.rs | 23 ++++--- src/identity/service.rs | 56 +++++++++++++---- src/models/action_assignment_rule.rs | 5 ++ src/models/capability.rs | 9 +++ src/models/entity.rs | 8 +++ src/models/token.rs | 4 ++ tests/m27_config_managed_identity.rs | 32 +++++----- 16 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 app/components/crud/managed-by-badge.tsx diff --git a/app/components/crud/crud-table.tsx b/app/components/crud/crud-table.tsx index 4ab4814d..66844158 100644 --- a/app/components/crud/crud-table.tsx +++ b/app/components/crud/crud-table.tsx @@ -28,6 +28,7 @@ import { CrudInspectSheet } from "@/components/crud/table/inspect-sheet"; import type { CrudTableProps, Row } from "@/components/crud/table/types"; import { defer, + isConfigManagedRow, isDeletedRow, singularize, tenantActionPastTense, @@ -438,6 +439,21 @@ function TableRowActions({ row: Row; tenantStatusPending: boolean; }) { + // Rows carrying `managed_by='config'` in the database were provisioned + // from the Atom bootstrap YAML. The API rejects update/delete/restore on + // them with 409 conflict, so hide the mutation buttons and offer only + // Inspect — mirrors the isDeletedRow pattern below. See + // components/crud/managed-by-badge.tsx. + if (isConfigManagedRow(row)) { + return ( +
+ +
+ ); + } + if (isDeletedRow(row)) { return (
diff --git a/app/components/crud/managed-by-badge.tsx b/app/components/crud/managed-by-badge.tsx new file mode 100644 index 00000000..7c5429c5 --- /dev/null +++ b/app/components/crud/managed-by-badge.tsx @@ -0,0 +1,41 @@ +import { Lock } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Renders "Config" when a row was provisioned from the Atom bootstrap YAML. + * Rows carrying `managed_by='config'` are read-only through the API — + * update/delete/revoke calls return 409 conflict — so the UI surfaces this + * marker and disables their mutation buttons. + */ +export function ManagedByBadge({ + managedBy, + className, +}: { + managedBy?: string | null; + className?: string; +}) { + if (managedBy !== "config") return null; + return ( + + + Config + + ); +} + +/** Row-shape predicate: true when the row must be shown read-only in the UI. */ +export function isConfigManaged(row: { managedBy?: string | null }): boolean { + return row.managedBy === "config"; +} + +/** Tooltip text for a disabled button on a config-managed row. */ +export const CONFIG_MANAGED_TOOLTIP = + "Managed by the bootstrap config file. Edit the YAML and restart Atom to change."; diff --git a/app/components/crud/table/cell-rendering.tsx b/app/components/crud/table/cell-rendering.tsx index a995b184..8caeda39 100644 --- a/app/components/crud/table/cell-rendering.tsx +++ b/app/components/crud/table/cell-rendering.tsx @@ -1,3 +1,4 @@ +import { ManagedByBadge } from "@/components/crud/managed-by-badge"; import { StatusBadge } from "@/components/crud/status-badge"; import { DisplayTimeCell } from "@/components/display-time"; import { DisplayTags } from "@/components/view-tags"; @@ -17,6 +18,9 @@ export function renderCell( key?: string, nameMap?: Map, ) { + if (key === "managedBy") { + return ; + } if (value === null || value === undefined || value === "") { return -; } diff --git a/app/components/crud/table/utils.ts b/app/components/crud/table/utils.ts index af795aa5..15bbe4ba 100644 --- a/app/components/crud/table/utils.ts +++ b/app/components/crud/table/utils.ts @@ -5,6 +5,16 @@ export function isDeletedRow(row: Row) { return Boolean(row.deletedAt) || String(row.status ?? "") === "deleted"; } +/** + * A row is "config-managed" when it was provisioned from the Atom bootstrap + * YAML file. The API rejects update/delete/restore on these rows with 409 + * conflict, so the UI hides mutation buttons — see + * `components/crud/managed-by-badge.tsx` for the shared badge. + */ +export function isConfigManagedRow(row: Row) { + return String(row.managedBy ?? "") === "config"; +} + export function tenantActionPastTense( action: keyof typeof TENANT_STATUS_MUTATIONS, ) { diff --git a/app/components/entities/entity-credentials.tsx b/app/components/entities/entity-credentials.tsx index eb8aff17..7581fb65 100644 --- a/app/components/entities/entity-credentials.tsx +++ b/app/components/entities/entity-credentials.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; +import { ManagedByBadge } from "@/components/crud/managed-by-badge"; import { StatusBadge } from "@/components/crud/status-badge"; import { DisplayTimeCell } from "@/components/display-time"; import { Badge } from "@/components/ui/badge"; @@ -34,6 +35,7 @@ const CREDENTIALS_QUERY = ` identifier expiresAt createdAt + managedBy } total } @@ -63,6 +65,7 @@ const ENTITY_ACCESS_TOKENS_QUERY = ` credentialId name scoped + managedBy permissions { actions scopeMode @@ -177,6 +180,7 @@ type Credential = { identifier: string | null; expiresAt: string | null; createdAt: string; + managedBy: string | null; }; type CredentialKind = "password" | "api_key" | "shared_key" | "certificate"; @@ -194,6 +198,7 @@ type EntityAccessToken = { credentialId: string; name: string; scoped: boolean; + managedBy: string | null; permissions: TokenPermission[]; lastUsedAt: string | null; }; @@ -958,6 +963,11 @@ function CredentialRow({ downloading: boolean; revealing: boolean; }) { + // Config-managed credentials are provisioned from the bootstrap YAML; the + // API refuses revoke/reveal/replace with 409 (or, for reveal, not_found), + // so hide the action buttons entirely and surface the badge instead. + const configManaged = + cred.managedBy === "config" || token?.managedBy === "config"; return (
@@ -969,6 +979,7 @@ function CredentialRow({ {token?.scoped ? scoped : null} +
{cred.identifier ? (
@@ -1015,7 +1026,7 @@ function CredentialRow({
- {cred.status === "active" ? ( + {cred.status === "active" && !configManaged ? (
{onDownload ? (