diff --git a/.env.example b/.env.example index 6a1a0087..6c28c57d 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,13 @@ 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 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 + # --- 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/AGENTS.md b/AGENTS.md index 6db146d6..cda62854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,13 @@ 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, 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) @@ -206,6 +211,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). @@ -221,6 +227,56 @@ must then be confined to a private network or a service mesh that provides transport security, and a startup warning is logged. (The HTTP rate limiter does not cover gRPC; see backlog #10.) +## Callouts (external policy hooks) + +Atom can consult an external policy service before executing configured +GraphQL resolvers or gRPC methods, and refuse the operation on a DENY. +Pattern is a Rust port of magistrala v0.14's `pkg/callout` + per-domain +middlewares. + +- **Config-driven**, per-operation opt-in. `callouts.yaml` (loaded when + `ATOM_CALLOUTS_FILE` is set) lists reusable HTTP or gRPC `endpoints:` and a + set of `operations:` that opt in by resolver name (GraphQL) or + fully-qualified method (gRPC). Kill-switch: `ATOM_CALLOUTS_ENABLED=false`. + Env overrides per endpoint id: `ATOM_CALLOUT__URL`, `_ADDRESS`, + `_TIMEOUT_MS`. See `callouts.example.yaml`. +- **Two transports, one wire shape.** HTTP (POST/GET, TLS + mTLS via reqwest) + and gRPC (tonic client of `atom.v1.callout.Callout/Check` — see + `proto/atom/v1/callout.proto`). Both send the same canonical envelope + (operation, surface, request_id, time, actor, args, extra); GET flattens + it to a query string for magistrala v0.14 parity. +- **Field selection.** Each operation entry has an `include:` list of + dot-paths (`actor.entity_id`, `args.input.name`) — a whitelist. `extra:` is + a static payload merged in from config. Independent of `include:`, a hard + denylist strips keys named `secret`, `password`, or `key` at any depth as + a safety net. +- **Chain semantics.** Multiple endpoints per operation run **sequentially, + fail-fast** — all must ALLOW for the operation to proceed. First non-ALLOW + short-circuits with the endpoint's reason. Transport error / timeout + applies the per-endpoint `on_error:` policy (default `deny` — matches + atom's default-deny invariant). +- **Where it runs in the request pipeline.** For both surfaces: + authn → **callout** → scope gates (`RequireManage` / `require_any_capability`) → + PDP → repo mutation. The extension owns the "before" hook only; overrides + and post-hooks are deliberately out of scope for v1 (post-execution + notifications remain the AMQP event outbox's job). +- **GraphQL wiring**: `graphql::callout_ext::CalloutExtensionFactory` + registered on the schema builder. `parse_query` walks the parsed document, + resolves variables, and records one pending callout per top-level field + that has a matching entry in `CalloutService`. `execute` runs the chain + and returns `Response::from_errors(...)` on the first DENY. Adding a new + GraphQL op to callouts is config-only. +- **gRPC wiring**: each of the ~6 gRPC methods calls a shared + `callout_check_grpc(...)` helper in `src/grpc.rs`, keyed by a `const` + operation name (`callout_ops::*`). Adding a *new* gRPC method to callouts + is config + one line here — deliberate, since the gRPC surface is small + and generic prost reflection wasn't worth the complexity. +- **Metrics + audit.** `atom_callout_calls_total{operation, endpoint, + transport, result}` and `atom_callout_call_duration_seconds` through the + metrics façade. Every DENY writes an `audit_logs` row + (`event="callout.deny"`, target_kind="callout", details include operation + + endpoint id + reason) fire-and-forget through `audit::write`. + ## Metrics Prometheus metrics are exposed at `GET /metrics` (text exposition). All metric diff --git a/Cargo.lock b/Cargo.lock index d22a1a45..44992200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,8 @@ dependencies = [ "argon2", "async-graphql", "async-graphql-axum", + "async-graphql-value", + "async-trait", "axum", "base64 0.22.1", "chrono", @@ -441,11 +443,14 @@ dependencies = [ "openidconnect", "p256", "prost", + "prost-types", "rand 0.8.6", "rcgen", + "reqwest", "ring", "serde", "serde_json", + "serde_yaml", "sqlx", "thiserror 1.0.69", "time", @@ -3758,6 +3763,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 +4771,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..e5be7d46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,11 +12,14 @@ path = "src/main.rs" [dependencies] axum = { version = "0.7", features = ["json", "macros"] } async-graphql = { version = "=7.2.1", default-features = false } +async-graphql-value = "=7.2.1" async-graphql-axum = "=7.0.13" +async-trait = "0.1" 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"] } @@ -38,6 +41,7 @@ base64 = "0.22" tonic = { version = "0.12", features = ["tls"] } tonic-health = "0.12" prost = "0.13" +prost-types = "0.13" lettre = { version = "0.11.21", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } minijinja = { version = "2", default-features = false, features = ["serde"] } url = "2" @@ -54,6 +58,10 @@ lapin = { version = "4.10.0", default-features = false, features = [ "rustls--ring", "rustls-webpki-roots-certs", ] } +# Used by src/callout/http.rs for calling out to external policy services. +# rustls (not native-tls) so the deployment does not need OpenSSL at build/run +# time; matches lapin's TLS backend. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [features] # Metrics are on by default. Disable at compile time for maximum-performance diff --git a/README.md b/README.md index a7fbc15a..5af6a9da 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 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 | @@ -777,6 +778,88 @@ 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 whole RBAC baseline — tenants, entities and their credentials, +resources, principal groups, object groups, permission blocks, roles and +policies — at startup: + +```bash +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 + kind: human + name: admin + credentials: + - kind: password + secret: change-me-please + # 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 + +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 +``` + +Sections are applied in dependency order: `tenants` → `entities` (+ +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` +(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`, `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. + --- ## Authentication 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 ? (