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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).

Expand All @@ -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_<UPPER_ID>_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
Expand Down
24 changes: 24 additions & 0 deletions Cargo.lock

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

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"
Expand All @@ -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
Expand Down
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions app/components/crud/crud-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="flex justify-end gap-2">
<Button onClick={onInspect} size="sm" variant="outline">
Inspect
</Button>
</div>
);
}

if (isDeletedRow(row)) {
return (
<div className="flex justify-end gap-2">
Expand Down
41 changes: 41 additions & 0 deletions app/components/crud/managed-by-badge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
className={cn(
"inline-flex h-5 w-fit shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
"border-slate-500/40 bg-slate-500/10 text-slate-700 dark:border-slate-400/40 dark:text-slate-300",
className,
)}
title="Managed by the bootstrap config file — read-only through the API"
>
<Lock className="h-3 w-3" aria-hidden />
Config
</span>
);
}

/** 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.";
4 changes: 4 additions & 0 deletions app/components/crud/table/cell-rendering.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +18,9 @@ export function renderCell(
key?: string,
nameMap?: Map<string, string>,
) {
if (key === "managedBy") {
return <ManagedByBadge managedBy={value as string | null | undefined} />;
}
if (value === null || value === undefined || value === "") {
return <span className="text-muted-foreground">-</span>;
}
Expand Down
10 changes: 10 additions & 0 deletions app/components/crud/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
Loading
Loading