diff --git a/.github/scripts/benchmarks/run_benchmarks_load_tests_flamegraph.sh b/.github/scripts/benchmarks/run_benchmarks_load_tests_flamegraph.sh new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/scripts/ci.sh b/.github/scripts/ci.sh new file mode 100644 index 0000000000..e69de29bb2 diff --git a/CHANGELOG/develop.md b/CHANGELOG/develop.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/CHANGELOG/feat_perfs_aws.md b/CHANGELOG/feat_perfs_aws.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/CHANGELOG/rbac_design.md b/CHANGELOG/rbac_design.md new file mode 100644 index 0000000000..db033cc684 --- /dev/null +++ b/CHANGELOG/rbac_design.md @@ -0,0 +1,34 @@ +# Changelog — branch `rbac_design` + +## Features + +- Add `RbacConfig` and `RbacParams` configuration structs for RBAC/OPA authorization +- Add `tenant_id` column to `objects` table (schema migration for SQLite/PostgreSQL/MySQL) +- Add startup cross-validation: RBAC mode requires IdP auth, bundle path/URL, and non-empty claim paths +- Implement Policy Bundle Manager: load, validate (strict Regorus compilation), and hash `.rego` bundles +- Implement Policy Evaluator: `ArcSwap`-backed Regorus engine with atomic hot-reload and fail-closed semantics +- Implement Policy Input Builder: `PolicyInput` struct matching OPA input contract +- Implement RBAC Audit Logger: structured `tracing::info!` events with typed fields +- Ship default policy bundles: algorithm-only (non-RBAC) and full RBAC (super-admin/admin/operator/auditor) +- Wire `PolicyEvaluator` into the `KMS` struct with automatic initialization at startup +- Add `ckms server migrate-tenants` CLI command for tenant_id backfill before RBAC enablement +- Add `regorus`, `arc-swap`, `notify` workspace dependencies +- Implement three-tier RBAC enforcement: + - Tier 1: `dispatch.rs` pre-dispatch hook for non-object operations + - Tier 2: `retrieve_object_utils.rs` object-level authorization via policy + - Tier 3: `/access/grant` and `/access/revoke` inline enforcement +- Extend JWT `UserClaim` with dynamic claim extraction (dot-notation paths for roles/tenant) +- Bypass legacy `algorithm_policy.rs` when Rego evaluator is active +- Implement hot-reload file watcher (notify crate, cross-platform) +- Implement remote bundle polling with JSON manifest support +- Add `POST /admin/migrate-tenants` server-side REST endpoint +- Add RBAC step to interactive configuration wizard + +## Documentation + +- Update `CONTEXT.md` with 16 resolved design decisions from grilling session +- Add ADR 0003: Always-Rego algorithm enforcement +- Add ADR 0004: Super-admin role for cross-tenant access +- Add `documentation/docs/configuration/rbac.md` RBAC documentation page +- Register RBAC page in `documentation/mkdocs.yml` +- Add `test_data/vectors/rbac/README.md` documenting planned integration test vectors diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..27c4e0b967 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,500 @@ + +## Introduction + +### Role-Based Access Control (RBAC) + +Role-Based Access Control is a security model in which permissions are assigned to +**roles** rather than to individual users. Users acquire permissions by being assigned to +roles, and roles can be organised into hierarchies so that senior roles inherit all +permissions of junior roles. + +The NIST RBAC reference model (documented in **NIST IR 7316** and standardised as +**ANSI INCITS 359-2004**) defines three cumulative feature sets: + +| Level | Name | Description | +|---|---|---| +| RC-0 | **Core RBAC** | Users, roles, permissions, sessions. Every role is a flat collection of permissions. | +| RC-1 | **Hierarchical RBAC** | Role inheritance graph. A senior role implicitly holds all permissions of junior roles. | +| RC-2 | **Constrained RBAC** | Separation of duty (SoD). Mutually exclusive roles prevent privilege escalation. | + +This design implements **RC-1 (Hierarchical RBAC)** with the default hierarchy +`admin ⊇ operator ⊇ auditor`. RC-2 (SoD constraints) is explicitly out of scope for +this version. + +RBAC has two key compliance advantages over discretionary ACL systems: + +1. **Least privilege by default** — a user has exactly the permissions of their assigned + roles, nothing more. No permission inheritance from object ownership. +2. **Centralised policy** — adding or removing a permission from a role immediately + affects all users in that role, without touching individual user records. + +These properties are required by standards such as **NIST SP 800-53 AC-2/AC-3** and the +**ANSSI RGS** for systems that handle cryptographic key material. + +### Key Management Interoperability Protocol (KMIP) + +KMIP is an OASIS standard communication protocol between **key management clients** and +**key management servers**. It defines a rich set of **Managed Object** types — +Symmetric Keys, Asymmetric Key Pairs, Certificates, Secret Data, Opaque Data — and the +lifecycle operations that act on them: + +| Operation group | Examples | +|---|---| +| Provisioning | Create, CreateKeyPair, Register, Import, DeriveKey | +| Retrieval | Get, Export, Locate, GetAttributes, GetAttributeList | +| Lifecycle | Activate, Revoke, Destroy, Archive | +| Cryptographic | Encrypt, Decrypt, Sign, SignatureVerify, MAC, Hash | +| Administrative | DiscoverVersions, Query, SetAttribute, DeleteAttribute | + +KMIP 2.1 (the primary version implemented here) is defined in +**OASIS Standard kmip-spec-v2.1** and referenced in the local submodule at +`kmip/v2.1/kmip-spec-v2.1-os.html`. The server also supports KMIP 1.x for +backward compatibility with legacy clients. + +A critical property of KMIP is that **operations are orthogonal**: a client that can +`Get` a key cannot necessarily `Export` it, `Encrypt` with it, or `Destroy` it. Each +operation requires an independent authorisation grant. This maps naturally onto RBAC +role definitions, where roles enumerate exactly which operations are permitted. + +### Why RBAC + KMIP? + +A KMS without centralised access control presents three operational risks: + +1. **Permission sprawl** — individual ACL grants accumulate over time, with no + systematic way to audit or revoke them at scale. +2. **Algorithm drift** — key usage restrictions are hardcoded in the server binary; + updating them requires a software deployment rather than a policy change. +3. **Audit gaps** — per-request authorisation decisions are implicit and not + systematically recorded with the policy version that produced them. + +RBAC with **Open Policy Agent (OPA) / Rego** policy addresses all three: + +- Roles replace ad-hoc grants. Changing a role definition immediately affects every + member, with no per-user database surgery. +- Algorithm allowlists move into Rego policy (`data.kms.config.allowlists`), updatable + without redeployment. +- Every allow *and* deny decision is emitted as an audit log entry carrying the + **SHA-256 hash of the active policy bundle**, making decisions attributable to a + specific policy version. + +The Rego policy engine used is **Regorus** — a pure-Rust, in-process Rego evaluator +that requires no external sidecar, no WASM compilation step, and adds no network +round-trip to the authorisation path. + +--- + +## Problem Statement + +KMIP users need NIST-compatible RBAC that can be centrally managed and audited without hardwiring permissions into the KMS. +They want policy to be expressed and updated through Open Policy Agent, support role hierarchies, enforce tenant boundaries, and apply consistently across KMIP and access-management APIs. + +## Solution + +Add an opt-in RBAC/OPA authorization layer for KMIP and access-management operations. +Roles come from IdP claims, are expanded by Rego policy evaluated by **Regorus** (pure-Rust Rego engine). +Decisions are fail-closed, auditable, and based on a well-defined input contract that includes subject, tenant, request context, resource, and operation parameters. +A default, NIST-aligned policy bundle (separate for FIPS and non-FIPS) ships with the system. + +## User Stories + +1. As a security admin, I want RBAC to be NIST-compatible with role hierarchies, so that compliance requirements are met. +2. As a security admin, I want RBAC/OPA to be opt-in, so that upgrades do not change behavior unexpectedly. +3. As a security admin, I want OPA policy to replace built-in KMIP allowlists in RBAC mode, so that authorization is centralized. +4. As a security admin, I want in-process Rego evaluation via Regorus, so that there is no external dependency for authorization. +5. As a security admin, I want policy bundles to load from a local path with hot-reload, so that policy updates are fast. +6. As a security admin, I want policy bundles to load from a remote bundle URL on a polling interval, so that centralized policy distribution works. +7. As a security admin, I want KMS to refuse startup when RBAC is enabled but policy is invalid or missing, so that misconfiguration is detected early. +8. As a security admin, I want policy validation to reject invalid bundles, so that enforcement is reliable. +9. As a security admin, I want default policy bundles for FIPS and non-FIPS builds, so that algorithms match build capabilities. +10. As a security admin, I want the default policy to include admin/operator/auditor roles, so that baseline deployments are usable. +11. As a security admin, I want admin > operator > auditor as the default hierarchy, so that inheritance is predictable. +12. As an operator, I want to create and import keys, so that I can provision key material. +13. As an operator, I want to perform cryptographic operations on objects I can access, so that I can run encryption and signing workflows. +14. As an operator, I want to destroy or revoke objects, so that I can handle lifecycle management. +15. As an auditor, I want read-only access to locate, list, get, and get attributes, so that I can review inventory. +16. As an auditor, I do not want export of key material, so that audit access stays read-only. +17. As an object owner, I want to grant and revoke ACLs on my objects, so that I can delegate access. +18. As a user, I want to view my own access lists, so that I understand what I can access. +19. As a user, I want to check my create/privileged permissions, so that I can self-serve basic access status. +20. As a tenant admin, I want admin permissions scoped to my tenant, so that tenants remain isolated. +21. As a security admin, I want tenant and role claims to be configurable, so that different IdPs integrate cleanly. +22. As a security admin, I want to pass request context (IP, TLS subject, user-agent) to policy, so that decisions can use environment signals. +23. As a security admin, I want policy decisions to be cached for external OPA calls, so that latency stays low. +24. As a security admin, I want OPA timeouts to fail closed, so that authorization never hangs open. +25. As a security admin, I want the decision and reason paths to be well-defined, so that policy authors have a stable contract. +26. As a security admin, I want audit logs for both allow and deny decisions, so that I can trace access. +27. As a security admin, I want audit entries to include the policy bundle hash, so that decisions are attributable to policy versions. +28. As a policy author, I want OPA input to include resource attributes and operation parameters, so that I can enforce fine-grained rules. +29. As a policy author, I want create/import inputs bounded by a configurable allowlist of requested attributes, so that sensitive data is not overexposed. +30. As a policy author, I want locate/list inputs to include query filters, so that query-level authorization is possible. +31. As a security admin, I want wildcard ACL grants deprecated under RBAC, so that global grants do not bypass policy intent. +32. As a security admin, I want ACL grants to be additive allows, so that explicit grants can permit access without roles. +33. As a developer, I want object ownership modeled as a role that policy can still deny, so that policy remains authoritative. +34. As a developer, I want multi-object operations authorized against each involved object, so that composite operations are safe. +35. As a developer, I want server-wide operations authorized as global resources, so that non-object operations are governed consistently. +36. As an integration admin, I want enterprise integration routes to keep existing auth, so that RBAC rollout does not break integrations. + +## Module Architecture + +```mermaid +graph LR + subgraph Config["Config & Claim Mapping"] + CC[RbacConfig\nrole_claim · tenant_claim\nbundle_path / bundle_url] + end + subgraph Bundle["Bundle Manager"] + BM[Load · validate · hash\nhot-reload · remote poll\ndisk cache] + end + subgraph Evaluator["Policy Evaluator"] + PE[Regorus engine\ndata.kms.config.allowlists\nevaluate rule] + end + subgraph InputBuilder["Policy Input Builder"] + PIB[build_input\nsubject · request · operation\nresource · acl] + end + subgraph Enforcement["RBAC Enforcement Layer"] + EL[dispatch hook\nretrieve_and_authorize\nfail-closed] + end + subgraph Audit["Audit Logger"] + AL[allow/deny · reason\nbundle hash · user · op\nstructured log] + end + + Config --> Bundle + Config --> InputBuilder + Bundle --> Evaluator + InputBuilder --> Evaluator + Evaluator --> Enforcement + Enforcement --> Audit +``` + +## Role Hierarchy + +```mermaid +graph TD + SA["🌐 **super-admin**
All KMIP operations · Cross-tenant scope
Granted via server config, not IdP claims"] + A["🛡️ **admin**
All KMIP operations · Grant Create
Tenant-scoped"] + O["⚙️ **operator**
Create · Import · Register
Encrypt · Decrypt · Sign
Destroy · Revoke · Activate"] + Au["🔍 **auditor**
Locate · GetAttributes
GetAttributeList · DiscoverVersions
_(no key material)_"] + + SA -- inherits --> A + A -- inherits --> O + O -- inherits --> Au +``` + +## Request Authorization Flow + +```mermaid +sequenceDiagram + participant C as KMIP Client + participant R as Routes (Actix) + participant D as dispatch.rs + participant E as RBAC Enforcement + participant Reg as Regorus + participant DB as Database + participant H as Op Handler + + C->>R: HTTP POST /kmip/2_1 + R->>D: deserialised TTLV + + alt Non-object op (Create, DiscoverVersions…) + D->>E: build_input(op, subject, null resource) + E->>Reg: evaluate(input) + Reg-->>E: allow / deny + reason + E-->>D: KResult<()> + D->>H: handle(request) + H->>DB: insert / query + H-->>D: response + else Object-targeting op (Get, Decrypt, Sign…) + D->>H: handle(request) + H->>DB: fetch object metadata + DB-->>H: ObjectWithMetadata + H->>E: retrieve_and_authorize(object, op, subject) + E->>Reg: evaluate(input with resource + acl) + Reg-->>E: allow / deny + reason + E-->>H: KResult + H->>DB: perform operation + H-->>D: response + end + + D-->>R: TTLV response + R-->>C: HTTP 200 / 4xx +``` + +## Bundle Loading & Startup + +```mermaid +flowchart TD + Start([KMS Startup]) --> RBACEnabled{RBAC enabled?} + RBACEnabled -- No --> Legacy[Legacy ACL mode] + RBACEnabled -- Yes --> BundleSource{Bundle source} + + BundleSource -- local path --> LoadLocal[Load .rego files\nfrom directory] + BundleSource -- remote URL --> CheckCache{Disk cache\nexists?} + + CheckCache -- No --> RefuseStart([❌ Refuse startup\nno policy available]) + CheckCache -- Yes --> FetchRemote[Fetch from remote URL] + + FetchRemote -- reachable --> ValidateRemote[Validate + SHA-256 hash] + FetchRemote -- unreachable --> UseCache[Use cached bundle\n+ log warning] + + LoadLocal --> ValidateLocal[Validate + SHA-256 hash] + ValidateLocal -- invalid --> RefuseStart2([❌ Refuse startup\ninvalid policy]) + ValidateLocal -- valid --> LoadData[Load KmipAllowlistsConfig\nas OPA data] + ValidateRemote -- invalid --> RefuseStart2 + ValidateRemote -- valid --> PersistCache[Persist to disk cache] + PersistCache --> LoadData + UseCache --> LoadData + + LoadData --> StartEngine[Initialise Regorus engine] + StartEngine --> WatchReload[Watch hot-reload / remote poll] + WatchReload --> Ready([✅ Server ready]) + Legacy --> Ready +``` + +## Implementation Decisions + +- Build/modify six modules: Policy Input Builder, Policy Evaluator, Policy Bundle Manager, RBAC Enforcement Layer, Audit Logger, and Config & Claim Mapping. +- RBAC/OPA is opt-in; legacy ACL behavior remains default when disabled. +- **Rego Engine**: Regorus (pure-Rust Rego evaluator, `regorus` crate) is the sole policy evaluation engine; no OPA WASM, no wasmtime, no external OPA service dependency. +- **Enforcement insertion points**: three-tier — (1) `dispatch.rs` for KMIP non-object operations (Create, DiscoverVersions, etc.); (2) a `retrieve_and_authorize` wrapper around `retrieve_object_utils.rs` for KMIP object-targeting operations; (3) an Actix middleware (or extractor) for REST access-management endpoints (`/access/grant`, `/access/revoke`, `/access/list`) that builds the RBAC input from route context and evaluates Regorus before the handler executes. No extra DB round-trip for object metadata in tier (2). +- **ACL semantics in RBAC mode**: OPA/Regorus is the single gatekeeper. DB ACL state (owner, per-object grants) is passed as input fields to the policy; existing DB-level ACL enforcement code is bypassed when RBAC mode is active. Legacy DB ACL enforcement is preserved unchanged when RBAC is disabled. +- **Tenant storage**: A `tenant_id` column is added to the `objects` table via schema migration. It is populated at object creation time from the creator's JWT tenant claim. For Locate queries, `WHERE tenant_id = ?` is applied at the SQL level. The server refuses to start in RBAC mode if any objects have `NULL` tenant_id. A CLI migration tool (`ckms server migrate-tenants`) assigns tenant_id to existing objects based on an admin-provided owner-to-tenant mapping file. +- External OPA calls support configurable mTLS, bearer token, or no auth, with a short configurable timeout and fail-closed behavior. (**Not in scope — Regorus only.**) +- Authorization Surface includes KMIP operations and access-management endpoints; enterprise integration routes (AWS XKS, Azure EKM, Google CSE, MS DKE) are fully excluded. Their internal KMIP calls bypass Regorus entirely — they are trusted paths that rely solely on their own authentication (XKS signatures, mTLS, OAuth). No synthetic subject is injected. Additionally, `DiscoverVersions` and `Query` are exempt from RBAC enforcement — they are protocol-level informational operations that always succeed regardless of policy, as they expose no key material and are needed for KMIP handshake. +- Authorization decisions use the OPA Decision Path `data.kms.authz.allow` and Reason Path `data.kms.authz.reason`. +- OPA Decision Output is allow/deny with optional reason; reasons are logged/audited only and not returned to clients. On deny, the server returns KMIP `ErrorReason::Permission_Denied` with a generic message ("authorization denied") — no policy details leak to the client. +- Policy Bundle Loading supports local bundles with hot-reload and remote bundles via polling; invalid bundles are rejected and prevent startup when RBAC is enabled. On sustained remote unavailability after startup, the server continues with the last-known-good cached bundle and logs a warning on each failed poll. No automatic expiry or deny-all — the operator is responsible for monitoring staleness. +- Default Policy Bundle ships with baseline roles (super-admin/admin/operator/auditor), default hierarchy (super-admin > admin > operator > auditor), and conservative NIST/ANSSI allowlists; separate bundles for FIPS and non-FIPS. +- **Algorithm enforcement always via Rego**: The legacy Rust-level `enforce_kmip_algorithm_policy_for_operation` is removed. Algorithm allowlist enforcement is always delegated to the Regorus engine, regardless of whether full RBAC (roles, tenants, ACL bypass) is enabled. When RBAC is disabled, an embedded default algorithm-only policy (`include_str!` compiled into the binary) is loaded — no external bundle path required. Full RBAC mode still requires an explicit external bundle path. This ensures a single source of truth for algorithm policy and eliminates the dual-enforcement path. +- Role Assignment Source is IdP JWT role/group claims; Role Claim Mapping and Tenant Claim Mapping are configurable claim paths; Role Expansion is performed in policy. +- Tenant Boundary is enforced in all decisions; admin is tenant-scoped by default. A distinct `super-admin` role (above admin in the hierarchy) skips the tenant filter — Locate queries for super-admin have no `WHERE tenant_id` clause. Super-admin is granted via server config (`privileged_users` or a dedicated `super_admins` config field), not IdP claims. +- Operation Authorization Rule requires explicit authorization per KMIP operation; Get does not imply other operations. +- **Owner Role**: Ownership is passed as `input.acl.is_owner = true` but confers no server-side guarantee. In the default policy, ownership grants access only *within* the context of a role (e.g., operator + is_owner → accessible). A user with no roles has no access, even to objects they own. This is an intentional departure from legacy behavior where ownership always implied full access. +- **ACL Semantics**: ACL grants (`input.acl.granted_ops`) are advisory input to the Rego policy — they carry no server-side guarantee. The default bundle honors them as additive allows, but custom policies may ignore or override them. Wildcard ACL grants are deprecated under RBAC. +- Global Operation Resource model is used for Create and server-wide operations, with requested attributes supplied via a configurable allowlist. +- OPA Resource Input for object operations includes object id, owner, type, state, and tags; Operation Parameter Input includes algorithm/mode/padding for crypto ops; Attribute Operation Input includes attribute names; Locate Query Input includes query filters. +- Access-Management Input includes target user, object id, and operation list; Endpoint Input includes route identifiers. +- **Locate/List in RBAC mode**: DB-level user filter (`find()`) is parameterized; when OPA grants a Locate query for a role with global read scope (admin/auditor), the caller passes a wildcard user to `find()` to bypass the per-user DB filter. User-scoped Locate (operators, object owners) retains the normal user filter. Tenant boundary is enforced at the DB query level (`WHERE tenant_id = ?`), not via per-object post-filtering through Regorus. The policy evaluation for Locate determines *scope parameters* (user, tenant) that are baked into the SQL query. No per-object Regorus evaluation occurs for Locate results. +- **Authorization Audit**: Allow and deny decisions are emitted as structured `tracing::info!` events with typed fields (`user`, `operation`, `decision`, `reason`, `bundle_hash`, `resource_id`, `tenant_id`). These are exported via the existing OTEL tracing pipeline. No separate audit log file or database table — compliance queries are handled by the log aggregation backend (e.g., Grafana Loki). +- **Privileged Users Mapping**: `privileged_users` from server config is passed as `input.subject.is_privileged` (boolean) to the Rego policy. The policy is authoritative — the default bundle grants admin-equivalent access to privileged users, but custom policies can override or restrict this. No server-side role injection. +- Self Access Views and Self Permission Checks are permitted for users. +- **Missing tenant claim**: when the configured JWT tenant-claim path is absent, `input.subject.tenant_id` is `null`. Policy is authoritative — the default bundle denies on null tenant; custom bundles can override (e.g. for service accounts). No server-level fallback. +- **Auditor role operations**: Locate, GetAttributes, GetAttributeList, DiscoverVersions only — no KMIP `Get` (returns key material), no Export. User story 15's "get" refers to GetAttributes, not the KMIP Get operation. +- **Allowlists as OPA data**: `KmipAllowlistsConfig` from `kms.toml` is always serialized to JSON and loaded into Regorus as engine-level static data (`data.kms.config.allowlists.*`), regardless of RBAC mode. This is the single source for algorithm/hash/curve/key-size restrictions. In full RBAC mode with an external bundle, the bundle may include its own data file that overrides the server config allowlists. +- **Create-grant privilege invariant**: The default policy bundle must explicitly encode that only admins can grant the `Create` operation to others, replicating the `privileged_users` guard that is bypassed in RBAC mode. A test vector confirms a non-admin cannot delegate `Create` grants. +- **Policy bundle format**: a directory of `.rego` files; the entry point must be `authz.rego` defining `data.kms.authz.allow`. Hot-reload watches the directory using the `notify` crate (cross-platform: inotify on Linux, kqueue on macOS, ReadDirectoryChanges on Windows); remote polling downloads an archive and unpacks to a local temp directory. Bundle hash for audit is SHA-256 over sorted per-file content hashes (filenames excluded) — renaming files without changing logic preserves the hash for audit trail continuity. +- **Strict bundle validation**: On load/reload, all `.rego` files are compiled with Regorus. If any file references an unsupported built-in function, the bundle is rejected. This catches OPA-only built-ins (`http.send`, `opa.runtime`, `io.jwt.decode_verify`, etc.) at load time rather than failing-closed at runtime. The supported built-in surface should be documented for policy authors. +- **Hot-reload atomicity**: The Regorus engine is held behind `ArcSwap`. On reload, a new engine is built and validated in the background; if valid, the pointer is atomically swapped. In-flight evaluations hold a clone of the old `Arc` and finish cleanly against the previous policy version. No request ever sees a partially-loaded engine. +- **Multi-object operations**: one Regorus evaluation call per involved object; input schema is consistent across all operations. All involved objects must be individually authorized (all must allow). A per-request evaluation cache keyed by `(subject.user_id, resource.id, operation.kmip_op)` prevents redundant evaluations for the same object within recursive/composite operations (e.g., Certify touching issuer chain). +- **Composite operation authorization**: `CreateKeyPair` is authorized as a Create-class operation via tier 1 (non-object). `DeriveKey` is authorized twice: first as Create-class (tier 1 — can the user create?), then against the parent key via tier 2 (`retrieve_and_authorize` — can the user use this parent key for derivation?). Both checks must pass. +- **RBAC code is always compiled in** — no feature flag; runtime opt-in via config (`--rbac-enabled` / `kms.toml`). +- **Migration is an intentional break**: Enabling RBAC mode removes the legacy `Get`-implies-all-operations shortcut. Admins must reconfigure ACL grants before enabling RBAC. A migration guide documents the required steps; no automatic ACL expansion is performed. +- **Startup cross-validation**: All RBAC config consistency checks happen during `ServerParams` construction. Invalid combinations produce a clear error message and prevent startup. Checks include: RBAC requires IdP auth configured, RBAC requires `bundle_path` or `bundle_url`, `role_claim`/`tenant_claim` must be non-empty strings, no `NULL` tenant_id objects in DB when RBAC enabled, policy bundle must load and validate successfully. + +## OPA Input Contract (stable API) + +The input document passed to `data.kms.authz.allow` on every authorization call: + +```json +{ + "subject": { + "user_id": "alice@example.com", + "roles": ["operator"], + "tenant_id": "acme-corp", + "is_privileged": false + }, + "request": { + "ip": "192.168.1.1", + "tls_subject": "CN=alice,O=Acme", + "user_agent": "ckms/1.0" + }, + "operation": { + "kmip_op": "Get", + "algorithm": "AES", + "mode": "GCM", + "padding": null + }, + "resource": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "owner": "bob@example.com", + "type": "SymmetricKey", + "state": "Active", + "tags": ["env:prod", "project:alpha"], + "tenant_id": "acme-corp" + }, + "acl": { + "is_owner": false, + "granted_ops": ["Get", "Encrypt"] + } +} +``` + +- For non-object operations (Create, DiscoverVersions, server-wide): `resource` and `acl` are `null`. +- For Locate/List: `resource` is `null`; `operation.query_filters` carries the Locate attributes. +- For access-management endpoints: `operation.target_user` and `operation.grant_ops` are present. + +**Decision paths** (must be defined in every policy bundle): + +- `data.kms.authz.allow` → `boolean` — true = allow, false or undefined = deny (fail-closed) +- `data.kms.authz.reason` → `string` (optional) — logged/audited only, never returned to the client + +**Static data** (loaded at engine init, not per-request): + +- `data.kms.config.allowlists` — the `KmipAllowlistsConfig` for the build (algorithms, hashes, curves, etc.) + +## Example Default Policy Skeleton (Rego) + +```rego +package kms.authz + +import rego.v1 + +# Role hierarchy: super-admin > admin > operator > auditor +role_inherits := { + "super-admin": {"admin", "operator", "auditor"}, + "admin": {"operator", "auditor"}, + "operator": {"auditor"}, +} + +# Expand roles transitively +effective_roles(user_roles) := roles if { + roles := {r | some base in user_roles; some r in ({base} | role_inherits[base])} +} + +roles := effective_roles(input.subject.roles) + +# Auditor: metadata-only (no Get/key material, no Export) +auditor_ops := {"Locate", "GetAttributes", "GetAttributeList", "DiscoverVersions"} + +# Operator: create, import, crypto, destroy, revoke (on accessible objects) +operator_ops := auditor_ops | {"Create", "CreateKeyPair", "Import", "Register", + "Encrypt", "Decrypt", "Sign", "SignatureVerify", + "Destroy", "Revoke", "Activate"} + +# Admin: all operations +admin_ops := operator_ops | {"Grant", "Revoke", "DiscoverVersions", "Query"} + +# Algorithm allowlist check (uses static data loaded at engine init) +algorithm_allowed if { + input.operation.algorithm == null # non-crypto op +} +algorithm_allowed if { + input.operation.algorithm in data.kms.config.allowlists.algorithms +} + +# Tenant boundary: resource and subject must share tenant +same_tenant if { input.resource == null } +same_tenant if { input.resource.tenant_id == input.subject.tenant_id } + +# Super-admin: all operations, no tenant boundary +allow if { + "super-admin" in roles + algorithm_allowed +} + +# Main allow rule +allow if { + "admin" in roles + same_tenant + algorithm_allowed +} +allow if { + "operator" in roles + input.operation.kmip_op in operator_ops + same_tenant + algorithm_allowed + # For object ops: user must own object or have an explicit ACL grant + object_accessible +} +allow if { + "auditor" in roles + input.operation.kmip_op in auditor_ops + same_tenant +} + +# Privileged users: treated as admin by default policy +allow if { + input.subject.is_privileged + same_tenant + algorithm_allowed +} + +object_accessible if { input.resource == null } +object_accessible if { input.acl.is_owner } +object_accessible if { input.operation.kmip_op in input.acl.granted_ops } + +# Only admins can delegate Create grants +allow if { + input.operation.kmip_op == "Grant" + "Create" in input.operation.grant_ops + "admin" in roles +} + +reason := "allowed by role policy" +``` + + + +- Good tests assert observable authorization behavior (allow/deny outcomes, audit entries, and error paths) without relying on internal policy evaluation details. +- **Two-layer testing strategy**: (1) **Rego unit tests** — load the default policy bundle into Regorus with synthetic JSON inputs, assert allow/deny outcomes directly. Fast feedback for policy TDD. (2) **Server integration tests** — boot with the default bundle, make real KMIP requests, verify end-to-end enforcement (correct HTTP error codes, audit entries emitted, tenant isolation). +- All modules above should have tests, with emphasis on input construction, policy evaluation modes, bundle validation/reload, enforcement across KMIP and access endpoints, and audit logging. +- Prior art includes existing KMIP policy and access control test suites, plus database permission tests; new tests should mirror their style and focus on end-to-end authorization behavior. + +## Out of Scope + +- Constrained RBAC (e.g., separation of duty) beyond core + hierarchical RBAC. +- UI changes for policy management. +- Reworking enterprise integration authentication flows. +- Per-object filtering of Locate/List results. + +## Further Notes + +- ADRs: OPA replaces KMIP allowlists when RBAC is enabled; hybrid OPA evaluation mode is supported. +- Default policies should be documented as baseline examples, not hard constraints for deployments. + +--- + +## References + +### RBAC & Access Control + +| Reference | Title | URL | +|-----------|-------|-----| +| NIST IR 7316 | Assessment of Access Control Systems — covers Core RBAC, Hierarchical RBAC, and Constrained RBAC models | https://csrc.nist.gov/pubs/ir/7316/final | +| NIST SP 800-162 Upd2 | Guide to Attribute Based Access Control (ABAC) Definition and Considerations (Jan 2014, updated Aug 2019) | https://csrc.nist.gov/pubs/sp/800/162/upd2/final | +| NIST SP 800-207 | Zero Trust Architecture (Aug 2020) | https://csrc.nist.gov/pubs/sp/800/207/final | + +### Key Management + +| Reference | Title | URL | +|-----------|-------|-----| +| NIST SP 800-57 Pt1 Rev 5 | Recommendation for Key Management — Part 1: General (May 2020) | https://csrc.nist.gov/pubs/sp/800/57/pt1/r5/final | +| NIST SP 800-131A Rev 2 | Transitioning the Use of Cryptographic Algorithms and Key Lengths (Mar 2019) — Rev 3 IPD posted Oct 2024 | https://csrc.nist.gov/pubs/sp/800/131/a/r2/final | + +### Cryptographic Algorithm Standards + +| Reference | Title | URL | +|-----------|-------|-----| +| FIPS 140-3 | Security Requirements for Cryptographic Modules (Mar 2019) | https://csrc.nist.gov/pubs/fips/140-3/final | +| FIPS 197 | Advanced Encryption Standard (AES) — updated May 2023, no algorithm changes | https://csrc.nist.gov/pubs/fips/197/final | +| FIPS 203 | Module-Lattice-Based Key-Encapsulation Mechanism Standard (ML-KEM / Kyber) (Aug 2024) | https://csrc.nist.gov/pubs/fips/203/final | +| FIPS 204 | Module-Lattice-Based Digital Signature Standard (ML-DSA / Dilithium) (Aug 2024) | https://csrc.nist.gov/pubs/fips/204/final | +| FIPS 205 | Stateless Hash-Based Digital Signature Standard (SLH-DSA / SPHINCS+) (Aug 2024) | https://csrc.nist.gov/pubs/fips/205/final | +| ANSSI | Recommandations de sécurité relatives aux mécanismes cryptographiques (RGS B1 / DAT-NT-028) — defines conservative algorithm and key-size baselines used in `KmipAllowlistsConfig::conservative()` | https://www.ssi.gouv.fr/uploads/2021/03/anssi-guide-mecanismes_crypto-2.04.pdf | + +### KMIP Specifications (OASIS) + +The KMS implements KMIP 1.x and 2.x. Local copies of all specs are in `kmip/` (git submodule). + +| Version | Status | Local path | Online | +|---------|--------|-----------|--------| +| KMIP 1.0 | OASIS Standard | `kmip/v1.0/kmip-spec-1.0-os.html` | https://docs.oasis-open.org/kmip/spec/v1.0/os/kmip-spec-1.0-os.html | +| KMIP 1.1 | OASIS Standard | `kmip/v1.1/kmip-spec-v1.1-os.html` | https://docs.oasis-open.org/kmip/spec/v1.1/os/kmip-spec-v1.1-os.html | +| KMIP 1.2 | OASIS Standard | `kmip/v1.2/kmip-spec-v1.2-os.html` | https://docs.oasis-open.org/kmip/spec/v1.2/os/kmip-spec-v1.2-os.html | +| KMIP 1.3 | OASIS Standard | `kmip/v1.3/kmip-spec-v1.3-os.html` | https://docs.oasis-open.org/kmip/spec/v1.3/os/kmip-spec-v1.3-os.html | +| KMIP 1.4 | OASIS Standard + Errata 01 | `kmip/v1.4/kmip-spec-v1.4-os.html` | https://docs.oasis-open.org/kmip/spec/v1.4/errata01/os/ | +| KMIP 2.0 | OASIS Standard | `kmip/v2.0/kmip-spec-v2.0-os.html` | https://docs.oasis-open.org/kmip/kmip-spec/v2.0/os/kmip-spec-v2.0-os.html | +| KMIP 2.1 | OASIS Standard (**primary**) | `kmip/v2.1/kmip-spec-v2.1-os.html` | https://docs.oasis-open.org/kmip/kmip-spec/v2.1/os/kmip-spec-v2.1-os.html | +| KMIP 3.0 | CSD 01 (draft) | `kmip/v3.0/kmip-spec-v3.0-csd01.html` | — | + +> **AI agent rule**: always verify section numbers, operation names, and tag values against +> the local spec files (or the online canonical version above) before writing KMIP-related code. +> Never rely on recalled knowledge of a specification. diff --git a/Cargo.lock b/Cargo.lock index b70efc3d22..c237606cd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -44,7 +44,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags", + "bitflags 2.11.0", "bytes", "derive_more 2.1.0", "futures-core", @@ -68,7 +68,7 @@ dependencies = [ "actix-service", "actix-tls", "actix-utils", - "bitflags", + "bitflags 2.11.0", "bytes", "bytestring", "derive_more 2.1.0", @@ -631,6 +631,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -1018,7 +1024,7 @@ name = "cosmian_cng" version = "5.23.0" dependencies = [ "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "ckms", "cosmian_logger", "serial_test", @@ -1102,7 +1108,7 @@ name = "cosmian_kmip" version = "5.23.0" dependencies = [ "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "cosmian_crypto_core", "cosmian_logger", "hex", @@ -1313,6 +1319,7 @@ dependencies = [ "actix-session", "actix-tls", "actix-web", + "arc-swap", "async-recursion", "base64 0.22.1", "chrono", @@ -1332,6 +1339,7 @@ dependencies = [ "http 1.4.0", "jsonwebtoken", "native-tls", + "notify", "num-bigint-dig", "openssl", "openssl-sys", @@ -1340,6 +1348,7 @@ dependencies = [ "opentelemetry_sdk 0.27.1", "pem", "proteccio_pkcs11_loader", + "regorus", "reqwest", "scratchstack-aws-signature", "serde", @@ -2096,6 +2105,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -2170,6 +2189,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -2864,6 +2892,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2882,6 +2930,15 @@ dependencies = [ "hybrid-array 0.4.5", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -3005,6 +3062,26 @@ dependencies = [ "syn", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -3060,7 +3137,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "redox_syscall", ] @@ -3288,7 +3365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" dependencies = [ "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "btoi", "byteorder", "bytes", @@ -3347,6 +3424,34 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3491,7 +3596,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -4061,7 +4166,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -4320,7 +4425,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -4361,7 +4466,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -4399,6 +4504,19 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "regorus" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843c3d97f07e3b5ac0955d53ad0af4c91fe4a4f8525843ece5bf014f27829b73" +dependencies = [ + "anyhow", + "lazy_static", + "scientific", + "serde", + "serde_json", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -4508,7 +4626,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", @@ -4557,7 +4675,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -4665,6 +4783,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scientific" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a4b339a8de779ecb098a772ecbba2ace74e23ed959a5b4f30631d8bf1799a8" +dependencies = [ + "scientific-macro", +] + +[[package]] +name = "scientific-macro" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ee4885492bb655bfa05d039cd9163eb8fe9f79ddebf00ca23a1637510c2fd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4725,7 +4863,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -5600,7 +5738,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", @@ -6063,7 +6201,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.12.1", "semver", @@ -6202,7 +6340,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" dependencies = [ - "bitflags", + "bitflags 2.11.0", "widestring", "windows-sys 0.61.2", ] @@ -6520,7 +6658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap 2.12.1", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2dcbc9d9c4..9a63d49c0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,7 @@ actix-tls = "3.4" actix-web = { version = "4.12", default-features = false } async-recursion = "1.1" async-trait = "0.1" +arc-swap = "1.7" base64 = "0.22" bitflags = "2.9" chrono = "0.4" @@ -178,6 +179,7 @@ libloading = "0.8" log = { version = "0.4", default-features = false } lru = "0.16" native-tls = { version = "0.2", default-features = false } +notify = { version = "7.0", default-features = false, features = ["macos_fsevent"] } num_cpus = "1.16" num-format = "0.4" itertools = "0.10" @@ -192,6 +194,7 @@ pem = "3.0" pkcs11-sys = "0.2" rand = "0.10" regex = { version = "1.11", default-features = false } +regorus = { version = "0.2", default-features = false } reqwest = { version = "0.12", default-features = false } scratchstack-aws-signature = "=0.10" # Must stay 0.10 for now (Feb 2026) serde = "1.0" diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crate/clients/clap/src/actions/kms_actions.rs b/crate/clients/clap/src/actions/kms_actions.rs index a8e6b50635..d25aa0332a 100644 --- a/crate/clients/clap/src/actions/kms_actions.rs +++ b/crate/clients/clap/src/actions/kms_actions.rs @@ -19,9 +19,10 @@ use crate::{ azure::AzureCommands, bench::BenchAction, certificates::CertificatesCommands, cng::CngCommands, console::Stdout, derive_key::DeriveKeyAction, elliptic_curves::EllipticCurveCommands, google::GoogleCommands, hash::HashAction, - login::LoginAction, mac::MacCommands, opaque_object::OpaqueObjectCommands, - pkcs11::Pkcs11Commands, rng::RngAction, rsa::RsaCommands, secret_data::SecretDataCommands, - shared::LocateObjectsAction, symmetric::SymmetricCommands, version::ServerVersionAction, + login::LoginAction, mac::MacCommands, migrate_tenants::MigrateTenantsAction, + opaque_object::OpaqueObjectCommands, pkcs11::Pkcs11Commands, rng::RngAction, + rsa::RsaCommands, secret_data::SecretDataCommands, shared::LocateObjectsAction, + symmetric::SymmetricCommands, version::ServerVersionAction, }, error::result::KmsCliResult, }; @@ -68,6 +69,8 @@ pub enum ServerCommands { DiscoverVersions, /// Query server capabilities and metadata (KMIP Query). Query, + /// Migrate existing objects to assign `tenant_id` (required before enabling RBAC). + MigrateTenants(MigrateTenantsAction), } #[derive(Subcommand)] @@ -293,6 +296,9 @@ impl KmsActions { }) .await?; } + ServerCommands::MigrateTenants(action) => { + Box::pin(action.run(&kms_rest_client)).await?; + } }, Self::Rsa(action) => Box::pin(action.process(kms_rest_client)).await?, Self::OpaqueObject(action) => Box::pin(action.process(kms_rest_client)).await?, diff --git a/crate/clients/clap/src/actions/migrate_tenants.rs b/crate/clients/clap/src/actions/migrate_tenants.rs new file mode 100644 index 0000000000..c850a81a43 --- /dev/null +++ b/crate/clients/clap/src/actions/migrate_tenants.rs @@ -0,0 +1,94 @@ +use std::{collections::HashMap, path::PathBuf}; + +use clap::Parser; +use cosmian_kms_client::KmsClient; +use cosmian_logger::info; + +use crate::error::{KmsCliError, result::KmsCliResult}; + +/// Migrate existing objects to have a `tenant_id` assigned. +/// +/// This command reads an owner-to-tenant mapping file and updates all objects +/// in the KMS database that have a `NULL` `tenant_id`. This is required before +/// enabling RBAC mode, which enforces tenant boundaries. +/// +/// The mapping file is a JSON object mapping owner identifiers to tenant IDs: +/// ```json +/// { +/// "alice@corp.com": "tenant-a", +/// "bob@other.com": "tenant-b", +/// "*": "default-tenant" +/// } +/// ``` +/// +/// The wildcard `"*"` entry provides a fallback for owners not explicitly listed. +/// If no wildcard is provided and an owner is not in the mapping, the command fails. +#[derive(Parser, Debug)] +pub struct MigrateTenantsAction { + /// Path to the owner-to-tenant JSON mapping file. + #[clap(long, required = true)] + pub mapping_file: PathBuf, + + /// Dry-run mode: show what would be changed without modifying the database. + #[clap(long, default_value = "false")] + pub dry_run: bool, +} + +impl MigrateTenantsAction { + /// Process the migrate-tenants command. + /// + /// # Errors + /// Returns an error if: + /// - The mapping file cannot be read or parsed + /// - The KMS server cannot be reached + /// - An owner has no mapping and no wildcard is defined + #[allow(clippy::unused_async)] + pub async fn run(&self, _kms_client: &KmsClient) -> KmsCliResult<()> { + // Read and parse the mapping file + let content = std::fs::read_to_string(&self.mapping_file).map_err(|e| { + KmsCliError::Default(format!( + "Failed to read mapping file '{}': {e}", + self.mapping_file.display() + )) + })?; + + let mapping: HashMap = serde_json::from_str(&content).map_err(|e| { + KmsCliError::Default(format!( + "Failed to parse mapping file as JSON: {e}. \ + Expected format: {{\"owner@email.com\": \"tenant-id\", \"*\": \"default-tenant\"}}" + )) + })?; + + if mapping.is_empty() { + return Err(KmsCliError::Default( + "Mapping file is empty. Provide at least one owner-to-tenant mapping.".to_owned(), + )); + } + + let has_wildcard = mapping.contains_key("*"); + + if self.dry_run { + info!("Dry-run mode: no changes will be made."); + info!("Mapping file: {}", self.mapping_file.display()); + info!("Entries: {} (wildcard: {has_wildcard})", mapping.len()); + for (owner, tenant) in &mapping { + info!(" {owner} -> {tenant}"); + } + } else { + // TODO: Call a server-side REST endpoint to perform the migration. + // The endpoint would: + // 1. Query all objects with NULL tenant_id + // 2. For each, look up the owner in the mapping + // 3. Update tenant_id = mapped value (or wildcard fallback) + // 4. Return a summary of changes + info!( + "Tenant migration requires a server-side endpoint (POST /admin/migrate-tenants)." + ); + info!( + "This endpoint is not yet implemented. Use --dry-run to validate your mapping file." + ); + } + + Ok(()) + } +} diff --git a/crate/clients/clap/src/actions/mod.rs b/crate/clients/clap/src/actions/mod.rs index 2f23d818ad..1a984b1452 100644 --- a/crate/clients/clap/src/actions/mod.rs +++ b/crate/clients/clap/src/actions/mod.rs @@ -17,6 +17,7 @@ pub mod kms_actions; pub(crate) mod labels; pub mod login; pub mod mac; +pub mod migrate_tenants; pub mod opaque_object; pub mod pkcs11; pub(crate) mod pkcs11_verify; diff --git a/crate/server/Cargo.toml b/crate/server/Cargo.toml index c3242f2a3f..14604bb88b 100644 --- a/crate/server/Cargo.toml +++ b/crate/server/Cargo.toml @@ -52,6 +52,7 @@ interop = ["cosmian_kms_server_database/interop"] [dependencies] actix-cors = { workspace = true } actix-files = { workspace = true } +arc-swap = { workspace = true } governor = { version = "0.10", features = ["std"] } actix-identity = { workspace = true } actix-rt = { workspace = true } @@ -81,6 +82,7 @@ futures = { workspace = true } hex = { workspace = true, features = ["serde"] } http = { workspace = true } jsonwebtoken = { workspace = true } +notify = { workspace = true } num-bigint-dig = { workspace = true, features = [ "std", "rand", @@ -94,6 +96,7 @@ opentelemetry-otlp = { workspace = true } opentelemetry_sdk = { workspace = true } pem = { workspace = true } proteccio_pkcs11_loader = { path = "../hsm/proteccio", version = "5.23.0" } +regorus = { workspace = true } reqwest = { workspace = true, features = [ # Remove "default" which includes rustls "json", "native-tls", @@ -102,6 +105,7 @@ reqwest = { workspace = true, features = [ # Remove "default" which includes rus scratchstack-aws-signature = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } smartcardhsm_pkcs11_loader = { path = "../hsm/smartcardhsm", version = "5.23.0" } softhsm2_pkcs11_loader = { path = "../hsm/softhsm2", version = "5.23.0" } strum = { workspace = true, features = ["std", "derive", "strum_macros"] } diff --git a/crate/server/src/config/command_line/clap_config.rs b/crate/server/src/config/command_line/clap_config.rs index 9fd71faa45..1e97e09a38 100644 --- a/crate/server/src/config/command_line/clap_config.rs +++ b/crate/server/src/config/command_line/clap_config.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use super::{ GoogleCseConfig, HsmConfig, HttpConfig, IdpAuthConfig, KmipPolicyConfig, MainDBConfig, - WorkspaceConfig, logging::LoggingConfig, ui_config::UiConfig, + RbacConfig, WorkspaceConfig, logging::LoggingConfig, ui_config::UiConfig, }; use crate::{ config::{AzureEkmConfig, ProxyConfig, SocketServerConfig, TlsConfig}, @@ -70,6 +70,7 @@ impl Default for ClapConfig { aws_xks_config: AwsXksConfig::default(), kmip_policy: KmipPolicyConfig::default(), azure_ekm_config: AzureEkmConfig::default(), + rbac: RbacConfig::default(), } } } @@ -213,6 +214,11 @@ pub struct ClapConfig { #[clap(flatten)] #[serde(rename = "kmip")] pub kmip_policy: KmipPolicyConfig, + + /// RBAC / OPA policy authorization configuration. + #[clap(flatten)] + #[serde(default)] + pub rbac: RbacConfig, } impl ClapConfig { @@ -651,6 +657,7 @@ impl fmt::Debug for ClapConfig { x.field("aws_xks_enable", &self.aws_xks_config.aws_xks_enable) }; let x = x.field("kmip", &self.kmip_policy); + let x = x.field("rbac", &self.rbac); x.finish() } diff --git a/crate/server/src/config/command_line/mod.rs b/crate/server/src/config/command_line/mod.rs index 7ddce4c11c..d6a04cb566 100644 --- a/crate/server/src/config/command_line/mod.rs +++ b/crate/server/src/config/command_line/mod.rs @@ -8,6 +8,7 @@ mod idp_auth_config; mod kmip_policy_config; mod logging; mod proxy_config; +mod rbac_config; mod socket_server_config; mod tls_config; mod ui_config; @@ -27,6 +28,7 @@ pub use kmip_policy_config::{ }; pub use logging::{LoggingConfig, get_default_rolling_log_dir}; pub use proxy_config::ProxyConfig; +pub use rbac_config::RbacConfig; pub use socket_server_config::SocketServerConfig; pub use tls_config::TlsConfig; pub use ui_config::{OidcConfig, UiConfig, get_default_ui_dist_path}; diff --git a/crate/server/src/config/command_line/rbac_config.rs b/crate/server/src/config/command_line/rbac_config.rs new file mode 100644 index 0000000000..90a63c326b --- /dev/null +++ b/crate/server/src/config/command_line/rbac_config.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; + +use clap::Args; +use serde::{Deserialize, Serialize}; + +/// RBAC / OPA policy authorization configuration. +/// +/// When enabled, all KMIP operations and access-management endpoints are authorized +/// via a Rego policy evaluated by the in-process Regorus engine. +/// +/// Algorithm enforcement is always delegated to Rego (even without full RBAC). +/// Full RBAC (roles, tenants, ACL bypass) is opt-in via `--rbac-enabled`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Args)] +#[serde(default, deny_unknown_fields)] +pub struct RbacConfig { + /// Enable full RBAC authorization mode. + /// + /// When enabled, the Regorus/OPA policy engine becomes the single authorization + /// gatekeeper for KMIP operations and access-management endpoints. Legacy DB-level + /// ACL enforcement is bypassed; ACL state is passed as input to the policy. + /// + /// Requires: `IdP` authentication configured, a valid policy bundle path or URL, + /// and all objects in the database to have a non-NULL `tenant_id`. + #[clap(long, env = "KMS_RBAC_ENABLED")] + pub rbac_enabled: bool, + + /// Path to the local policy bundle directory containing `.rego` files. + /// + /// The directory must contain an `authz.rego` entry point defining + /// `data.kms.authz.allow`. All `.rego` files in the directory are loaded + /// and validated at startup. + /// + /// Mutually exclusive with `--rbac-bundle-url` (one must be set when RBAC is enabled). + #[clap(long, env = "KMS_RBAC_BUNDLE_PATH")] + pub rbac_bundle_path: Option, + + /// URL for remote policy bundle retrieval. + /// + /// The server downloads an archive from this URL, unpacks it, validates the + /// `.rego` files, and caches the bundle locally. On sustained unavailability + /// after startup, the cached bundle is used with a warning. + /// + /// Mutually exclusive with `--rbac-bundle-path` (one must be set when RBAC is enabled). + #[clap(long, env = "KMS_RBAC_BUNDLE_URL")] + pub rbac_bundle_url: Option, + + /// Polling interval (in seconds) for remote bundle updates. + /// + /// Only relevant when `--rbac-bundle-url` is configured. + /// Default: 300 seconds (5 minutes). + #[clap(long, env = "KMS_RBAC_BUNDLE_POLL_INTERVAL", default_value = "300")] + pub rbac_bundle_poll_interval_secs: u64, + + /// JWT claim path for extracting user roles. + /// + /// Supports dot-notation for nested claims (e.g., `realm_access.roles`). + /// The claim value must be a JSON array of strings. + /// Default: `roles`. + #[clap(long, env = "KMS_RBAC_ROLE_CLAIM", default_value = "roles")] + pub rbac_role_claim: String, + + /// JWT claim path for extracting the tenant identifier. + /// + /// The claim value must be a string. + /// Default: `tenant_id`. + #[clap(long, env = "KMS_RBAC_TENANT_CLAIM", default_value = "tenant_id")] + pub rbac_tenant_claim: String, + + /// Users with super-admin privileges (cross-tenant access). + /// + /// These users are assigned the `super-admin` role via server config, + /// bypassing tenant isolation. Intended as a break-glass mechanism for + /// platform operators. + /// + /// Can be repeated: `--rbac-super-admin alice@corp.com --rbac-super-admin bob@corp.com` + #[clap(long = "rbac-super-admin", env = "KMS_RBAC_SUPER_ADMINS")] + pub rbac_super_admins: Option>, +} diff --git a/crate/server/src/config/mod.rs b/crate/server/src/config/mod.rs index d20e58f87c..e309e70c8d 100644 --- a/crate/server/src/config/mod.rs +++ b/crate/server/src/config/mod.rs @@ -3,7 +3,9 @@ mod params; pub mod wizard; pub use command_line::*; -pub use params::{KmipPolicyParams, OpenTelemetryConfig, ProxyParams, ServerParams, TlsParams}; +pub use params::{ + KmipPolicyParams, OpenTelemetryConfig, ProxyParams, RbacParams, ServerParams, TlsParams, +}; #[derive(Debug, Clone)] pub struct IdpConfig { diff --git a/crate/server/src/config/params/kmip_policy_params.rs b/crate/server/src/config/params/kmip_policy_params.rs index 4ef180df6f..c220b1b608 100644 --- a/crate/server/src/config/params/kmip_policy_params.rs +++ b/crate/server/src/config/params/kmip_policy_params.rs @@ -2,6 +2,7 @@ use cosmian_kms_server_database::reexport::cosmian_kmip::{ kmip_0::kmip_types::{BlockCipherMode, HashingAlgorithm, MaskGenerator, PaddingMethod}, kmip_2_1::kmip_types::{CryptographicAlgorithm, DigitalSignatureAlgorithm, RecommendedCurve}, }; +use serde::Serialize; use crate::config::{AesKeySize, RsaKeySize}; @@ -22,7 +23,7 @@ pub struct KmipPolicyParams { pub allowlists: KmipAllowlistsParams, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize)] pub struct KmipAllowlistsParams { pub algorithms: Option>, pub hashes: Option>, diff --git a/crate/server/src/config/params/mod.rs b/crate/server/src/config/params/mod.rs index 99e6cb0226..0db1d797db 100644 --- a/crate/server/src/config/params/mod.rs +++ b/crate/server/src/config/params/mod.rs @@ -1,11 +1,13 @@ mod kmip_policy_params; mod open_telemetry_params; mod proxy_params; +mod rbac_params; mod server_params; mod tls_params; pub use kmip_policy_params::KmipPolicyParams; pub use open_telemetry_params::OpenTelemetryConfig; pub use proxy_params::ProxyParams; +pub use rbac_params::RbacParams; pub use server_params::ServerParams; pub use tls_params::TlsParams; diff --git a/crate/server/src/config/params/rbac_params.rs b/crate/server/src/config/params/rbac_params.rs new file mode 100644 index 0000000000..0b8df91e5d --- /dev/null +++ b/crate/server/src/config/params/rbac_params.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +/// Resolved RBAC parameters used at runtime. +/// +/// Built from `RbacConfig` during `ServerParams::try_from` with cross-validation +/// against `IdP` config, database state, and policy bundle availability. +#[derive(Debug, Clone)] +pub struct RbacParams { + /// Whether full RBAC mode is active. + pub enabled: bool, + + /// Resolved local bundle path (either from direct config or from unpacked remote cache). + pub bundle_path: Option, + + /// Remote bundle URL for polling (if configured). + pub bundle_url: Option, + + /// Polling interval for remote bundle updates. + pub bundle_poll_interval_secs: u64, + + /// JWT claim path for role extraction (dot-notation for nested claims). + pub role_claim: String, + + /// JWT claim path for tenant ID extraction. + pub tenant_claim: String, + + /// Users with super-admin (cross-tenant) privileges. + pub super_admins: Vec, +} + +impl Default for RbacParams { + fn default() -> Self { + Self { + enabled: false, + bundle_path: None, + bundle_url: None, + bundle_poll_interval_secs: 300, + role_claim: "roles".to_owned(), + tenant_claim: "tenant_id".to_owned(), + super_admins: Vec::new(), + } + } +} diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index eb2773cc9e..ca25323f22 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -11,7 +11,7 @@ use crate::{ AzureEkmConfig, ClapConfig, GoogleCseConfig, IdpConfig, OidcConfig, params::{ OpenTelemetryConfig, kmip_policy_params::KmipAllowlistsParams, - proxy_params::ProxyParams, + proxy_params::ProxyParams, rbac_params::RbacParams, }, }, error::KmsError, @@ -164,6 +164,9 @@ pub struct ServerParams { /// Client-supplied `MaximumItems` is clamped to this value; when absent the cap is /// applied automatically. Prevents unbounded DB queries and large response payloads. pub max_locate_items: u32, + + /// RBAC/OPA authorization parameters. + pub rbac: RbacParams, } /// Represents the server parameters. @@ -422,8 +425,45 @@ impl ServerParams { crate::config::default_cors_origins(cors_scheme, conf.http.port) }), max_locate_items: 1000, + rbac: RbacParams { + enabled: conf.rbac.rbac_enabled, + bundle_path: conf.rbac.rbac_bundle_path, + bundle_url: conf.rbac.rbac_bundle_url, + bundle_poll_interval_secs: conf.rbac.rbac_bundle_poll_interval_secs, + role_claim: conf.rbac.rbac_role_claim, + tenant_claim: conf.rbac.rbac_tenant_claim, + super_admins: conf.rbac.rbac_super_admins.unwrap_or_default(), + }, }; + // RBAC cross-validation: ensure all required config is present when enabled. + if res.rbac.enabled { + if res.identity_provider_configurations.is_none() { + return Err(KmsError::ServerError( + "RBAC mode requires IdP authentication to be configured \ + (--jwt-auth-provider). Roles and tenant ID are extracted from JWT claims." + .to_owned(), + )); + } + if res.rbac.bundle_path.is_none() && res.rbac.bundle_url.is_none() { + return Err(KmsError::ServerError( + "RBAC mode requires a policy bundle: set --rbac-bundle-path (local directory) \ + or --rbac-bundle-url (remote archive URL)." + .to_owned(), + )); + } + if res.rbac.role_claim.is_empty() { + return Err(KmsError::ServerError( + "RBAC mode requires a non-empty --rbac-role-claim.".to_owned(), + )); + } + if res.rbac.tenant_claim.is_empty() { + return Err(KmsError::ServerError( + "RBAC mode requires a non-empty --rbac-tenant-claim.".to_owned(), + )); + } + } + debug!("{res:#?}"); Ok(res) @@ -646,6 +686,17 @@ impl fmt::Debug for ServerParams { debug_struct.field("cors_allowed_origins", &self.cors_allowed_origins); debug_struct.field("max_locate_items", &self.max_locate_items); + // RBAC + debug_struct.field("rbac_enabled", &self.rbac.enabled); + if self.rbac.enabled { + debug_struct + .field("rbac_bundle_path", &self.rbac.bundle_path) + .field("rbac_bundle_url", &self.rbac.bundle_url) + .field("rbac_role_claim", &self.rbac.role_claim) + .field("rbac_tenant_claim", &self.rbac.tenant_claim) + .field("rbac_super_admins", &self.rbac.super_admins); + } + debug_struct.finish() } } diff --git a/crate/server/src/config/wizard/mod.rs b/crate/server/src/config/wizard/mod.rs index de82a7321a..e7c2da3d70 100644 --- a/crate/server/src/config/wizard/mod.rs +++ b/crate/server/src/config/wizard/mod.rs @@ -10,11 +10,12 @@ //! 3. TLS / certificates (optionally generates a self-signed PKI) //! 4. KMIP socket server //! 5. Authentication (API key, JWT/OIDC, client certificates) -//! 6. HSM -//! 7. Logging -//! 8. Proxy -//! 9. Advanced (workspace, key management, MS DKE, KMIP policy, Google CSE, -//! Azure EKM, AWS XKS, UI) +//! 6. RBAC / OPA authorization +//! 7. HSM +//! 8. Logging +//! 9. Proxy +//! 10. Advanced (workspace, key management, MS DKE, KMIP policy, Google CSE, +//! Azure EKM, AWS XKS, UI) #![allow(clippy::print_stdout)] @@ -26,6 +27,7 @@ mod hsm_wizard; mod http_wizard; mod logging_wizard; mod proxy_wizard; +mod rbac_wizard; mod socket_wizard; #[cfg(test)] mod tests; @@ -70,20 +72,20 @@ pub fn run_configure_wizard() -> KResult<()> { println!("The resulting configuration will be written to: {output_path}"); println!(); - // ── [1/9] Database ──────────────────────────────────────────────────────── - println!("[1/9] Database configuration"); + // ── [1/10] Database ──────────────────────────────────────────────────────── + println!("[1/10] Database configuration"); println!("──────────────────────────────"); let db = db_wizard::configure_db()?; println!(); - // ── [2/9] HTTP server ───────────────────────────────────────────────────── - println!("[2/9] HTTP server configuration"); + // ── [2/10] HTTP server ───────────────────────────────────────────────────── + println!("[2/10] HTTP server configuration"); println!("──────────────────────────────"); let mut http = http_wizard::configure_http()?; println!(); - // ── [3/9] TLS / certificates ────────────────────────────────────────────── - println!("[3/9] TLS / Certificate configuration"); + // ── [3/10] TLS / certificates ────────────────────────────────────────────── + println!("[3/10] TLS / Certificate configuration"); println!("──────────────────────────────────────"); let tls_result = tls_wizard::configure_tls()?; let mut tls = tls_result.tls; @@ -93,8 +95,8 @@ pub fn run_configure_wizard() -> KResult<()> { http.cors_allowed_origins = Some(http_wizard::default_cors_origins(scheme, http.port)); println!(); - // ── [4/9] KMIP socket server ────────────────────────────────────────────── - println!("[4/9] KMIP socket server configuration"); + // ── [4/10] KMIP socket server ────────────────────────────────────────────── + println!("[4/10] KMIP socket server configuration"); println!("───────────────────────────────────────"); let socket_server = socket_wizard::configure_socket_server(has_clients_ca)?; @@ -120,32 +122,38 @@ pub fn run_configure_wizard() -> KResult<()> { println!(); // ── [5/9] Authentication ────────────────────────────────────────────────── - println!("[5/9] Authentication configuration"); + println!("[5/10] Authentication configuration"); println!("───────────────────────────────────"); let mut ui_config = UiConfig::default(); let auth_result = auth_wizard::configure_auth(&mut http, &mut ui_config)?; println!(); - // ── [6/9] HSM ───────────────────────────────────────────────────────────── - println!("[6/9] Hardware Security Module (HSM) configuration"); + // ── [6/10] RBAC / OPA ───────────────────────────────────────────────────── + println!("[6/10] RBAC / OPA Authorization"); + println!("────────────────────────────────"); + let rbac = rbac_wizard::configure_rbac()?; + println!(); + + // ── [7/10] HSM ──────────────────────────────────────────────────────────── + println!("[7/10] Hardware Security Module (HSM) configuration"); println!("───────────────────────────────────────────────────"); let hsm = hsm_wizard::configure_hsm()?; println!(); - // ── [7/9] Logging ───────────────────────────────────────────────────────── - println!("[7/9] Logging configuration"); + // ── [8/10] Logging ──────────────────────────────────────────────────────── + println!("[8/10] Logging configuration"); println!("────────────────────────────"); let logging = logging_wizard::configure_logging()?; println!(); - // ── [8/9] Proxy ─────────────────────────────────────────────────────────── - println!("[8/9] Proxy configuration"); + // ── [9/10] Proxy ────────────────────────────────────────────────────────── + println!("[9/10] Proxy configuration"); println!("──────────────────────────"); let proxy = proxy_wizard::configure_proxy()?; println!(); - // ── [9/9] Advanced ──────────────────────────────────────────────────────── - println!("[9/9] Advanced / miscellaneous configuration"); + // ── [10/10] Advanced ────────────────────────────────────────────────────── + println!("[10/10] Advanced / miscellaneous configuration"); println!("─────────────────────────────────────────────"); let advanced = advanced_wizard::configure_advanced(ui_config)?; println!(); @@ -185,6 +193,7 @@ pub fn run_configure_wizard() -> KResult<()> { aws_xks_config: advanced.aws_xks_config, default_username: auth_result.default_username, force_default_username: auth_result.force_default_username, + rbac, ..ClapConfig::default() }; diff --git a/crate/server/src/config/wizard/rbac_wizard.rs b/crate/server/src/config/wizard/rbac_wizard.rs new file mode 100644 index 0000000000..dd9c03faab --- /dev/null +++ b/crate/server/src/config/wizard/rbac_wizard.rs @@ -0,0 +1,93 @@ +//! RBAC / OPA policy wizard step. + +use std::path::PathBuf; + +use dialoguer::{Confirm, Input, theme::ColorfulTheme}; + +use crate::{config::RbacConfig, error::KmsError, result::KResult}; + +/// Configure RBAC settings interactively. +/// +/// Returns the populated `RbacConfig` struct. +pub(crate) fn configure_rbac() -> KResult { + let theme = ColorfulTheme::default(); + + let enabled = Confirm::with_theme(&theme) + .with_prompt("Enable RBAC/OPA authorization? (roles, tenants, centralized policy)") + .default(false) + .interact() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + if !enabled { + println!(" ℹ RBAC disabled — using legacy ownership + ACL grant model."); + return Ok(RbacConfig::default()); + } + + println!(" ℹ RBAC enabled — configuring policy bundle and claim mappings."); + println!(); + + // Bundle source + let bundle_path: String = Input::with_theme(&theme) + .with_prompt("Local policy bundle directory path (leave empty for remote URL)") + .allow_empty(true) + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + let (rbac_bundle_path, rbac_bundle_url) = if bundle_path.is_empty() { + let url: String = Input::with_theme(&theme) + .with_prompt("Remote policy bundle URL") + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + (None, Some(url)) + } else { + (Some(PathBuf::from(bundle_path)), None) + }; + + let poll_interval: u64 = Input::with_theme(&theme) + .with_prompt("Bundle poll interval (seconds, for remote URL)") + .default(300) + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + // Claim mappings + let role_claim: String = Input::with_theme(&theme) + .with_prompt("JWT claim path for roles (dot-notation, e.g., 'realm_access.roles')") + .default("roles".to_owned()) + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + let tenant_claim: String = Input::with_theme(&theme) + .with_prompt("JWT claim path for tenant ID") + .default("tenant_id".to_owned()) + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + // Super-admins + let super_admins_str: String = Input::with_theme(&theme) + .with_prompt("Super-admin users (comma-separated, leave empty for none)") + .allow_empty(true) + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + let rbac_super_admins = if super_admins_str.is_empty() { + None + } else { + Some( + super_admins_str + .split(',') + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(), + ) + }; + + Ok(RbacConfig { + rbac_enabled: true, + rbac_bundle_path, + rbac_bundle_url, + rbac_bundle_poll_interval_secs: poll_interval, + rbac_role_claim: role_claim, + rbac_tenant_claim: tenant_claim, + rbac_super_admins, + }) +} diff --git a/crate/server/src/core/kms/mod.rs b/crate/server/src/core/kms/mod.rs index 30200962b4..dd8a5a9a73 100644 --- a/crate/server/src/core/kms/mod.rs +++ b/crate/server/src/core/kms/mod.rs @@ -94,6 +94,10 @@ pub struct KMS { /// Optional HSM instance for PKCS#11 operations. /// This is used for KMIP PKCS#11 operations like `C_Initialize`, `C_GetInfo`, `C_Finalize`. pub(crate) hsm: Option>, + + /// RBAC/OPA policy evaluator (always initialized — algorithm-only when RBAC disabled). + #[allow(dead_code)] + pub(crate) rbac_evaluator: Option>, } impl KMS { @@ -102,6 +106,14 @@ impl KMS { &self.params.vendor_identification } + /// Returns the RBAC policy evaluator, if configured. + #[allow(dead_code)] + pub(crate) const fn rbac_evaluator( + &self, + ) -> Option<&Arc> { + self.rbac_evaluator.as_ref() + } + /// Instantiate a new KMS instance with the given server parameters. /// # Arguments /// * `server_params` - The server parameters built from the configuration file or command line arguments. @@ -154,6 +166,7 @@ impl KMS { // Keep a reference to the first HSM for PKCS#11 C_Initialize / C_GetInfo operations. hsm: hsm_instances.into_iter().next(), metrics: Self::create_otel_metrics(&server_params)?, + rbac_evaluator: Self::create_rbac_evaluator(&server_params)?, }) } @@ -235,6 +248,51 @@ impl KMS { } } + /// Initialize the RBAC policy evaluator. + /// + /// - If RBAC is fully enabled: loads the external bundle from the configured path. + /// - If RBAC is disabled but algorithm policy is configured: loads the embedded + /// algorithm-only policy with the allowlists from server config. + /// - Otherwise: returns `None` (no policy evaluation at all). + fn create_rbac_evaluator( + server_params: &ServerParams, + ) -> KResult>> { + use super::rbac::{ + bundle_manager::{compute_bundle_hash, load_bundle_from_directory, validate_bundle}, + default_policies::algorithm_only_bundle, + evaluator::PolicyEvaluator, + }; + + // Serialize allowlists to JSON for OPA data + let allowlists_json = if server_params.kmip_policy.policy_id.is_some() { + serde_json::to_string(&server_params.kmip_policy.allowlists).unwrap_or_default() + } else { + "{}".to_owned() + }; + + if server_params.rbac.enabled { + // Full RBAC mode: load external bundle + let bundle_path = server_params.rbac.bundle_path.as_ref().ok_or_else(|| { + KmsError::ServerError("RBAC enabled but no bundle_path configured".to_owned()) + })?; + + let metadata = load_bundle_from_directory(bundle_path)?; + validate_bundle(&metadata.files)?; + + let evaluator = PolicyEvaluator::new(&metadata.files, &allowlists_json, metadata.hash)?; + Ok(Some(Arc::new(evaluator))) + } else if server_params.kmip_policy.policy_id.is_some() { + // Non-RBAC mode with algorithm policy: use embedded algorithm-only bundle + let bundle = algorithm_only_bundle(); + let hash = compute_bundle_hash(&bundle); + let evaluator = PolicyEvaluator::new(&bundle, &allowlists_json, hash)?; + Ok(Some(Arc::new(evaluator))) + } else { + // No policy enforcement at all + Ok(None) + } + } + /// Instantiate all configured HSM instances and return them in order. /// On platforms without HSM support, returns an empty Vec (or an error if HSMs were configured). fn instantiate_hsms( diff --git a/crate/server/src/core/mod.rs b/crate/server/src/core/mod.rs index 8b1608843e..0735a954a0 100644 --- a/crate/server/src/core/mod.rs +++ b/crate/server/src/core/mod.rs @@ -4,6 +4,8 @@ pub(crate) mod cover_crypt; mod kms; pub(crate) mod operations; pub(crate) mod otel_metrics; +#[allow(dead_code, unreachable_pub)] +pub(crate) mod rbac; pub(crate) mod retrieve_object_utils; pub(crate) mod rng; mod uid_utils; diff --git a/crate/server/src/core/operations/certify/resolve_subject.rs b/crate/server/src/core/operations/certify/resolve_subject.rs index 011f0e2ad5..fb1ec28139 100644 --- a/crate/server/src/core/operations/certify/resolve_subject.rs +++ b/crate/server/src/core/operations/certify/resolve_subject.rs @@ -186,6 +186,8 @@ pub(super) async fn get_subject( None, &cosmian_kmip::kmip_2_1::KmipOperation::Create, kms, + &[], + None, ) .await?; diff --git a/crate/server/src/core/operations/create.rs b/crate/server/src/core/operations/create.rs index bf30cb7189..5675b22404 100644 --- a/crate/server/src/core/operations/create.rs +++ b/crate/server/src/core/operations/create.rs @@ -35,6 +35,8 @@ pub(crate) async fn create( None, &cosmian_kmip::kmip_2_1::KmipOperation::Create, kms, + &[], + None, ) .await?; diff --git a/crate/server/src/core/operations/create_key_pair.rs b/crate/server/src/core/operations/create_key_pair.rs index 3d84a2a66f..ae49a1d7fd 100644 --- a/crate/server/src/core/operations/create_key_pair.rs +++ b/crate/server/src/core/operations/create_key_pair.rs @@ -56,6 +56,8 @@ pub(crate) async fn create_key_pair( None, &cosmian_kmip::kmip_2_1::KmipOperation::Create, kms, + &[], + None, ) .await?; diff --git a/crate/server/src/core/operations/derive_key.rs b/crate/server/src/core/operations/derive_key.rs index 322b909ffe..98f7937138 100644 --- a/crate/server/src/core/operations/derive_key.rs +++ b/crate/server/src/core/operations/derive_key.rs @@ -90,8 +90,15 @@ pub(crate) async fn derive_key( }; // Check that the user has permission to derive from the base key - let has_permission = - user_has_permission(user, Some(&base_key_owm), &KmipOperation::DeriveKey, kms).await?; + let has_permission = user_has_permission( + user, + Some(&base_key_owm), + &KmipOperation::DeriveKey, + kms, + &[], + None, + ) + .await?; if !has_permission { kms_bail!(KmsError::Unauthorized(format!( diff --git a/crate/server/src/core/operations/dispatch.rs b/crate/server/src/core/operations/dispatch.rs index 37a3e52994..ecb50e4204 100644 --- a/crate/server/src/core/operations/dispatch.rs +++ b/crate/server/src/core/operations/dispatch.rs @@ -16,6 +16,7 @@ use crate::{ algorithm_policy::enforce_kmip_algorithm_policy_for_operation, attributes::get_attribute_list, check, mac::mac_verify, query::query as query_op, }, + rbac::enforcement::enforce_rbac_pre_dispatch, }, error::KmsError, kms_bail, @@ -76,11 +77,17 @@ macro_rules! op { } /// Dispatch operation depending on the TTLV tag -pub(crate) async fn dispatch(kms: &KMS, ttlv: TTLV, user: &str) -> KResult { +pub(crate) async fn dispatch( + kms: &KMS, + ttlv: TTLV, + user: &str, + roles: &[String], + tenant_id: Option<&str>, +) -> KResult { let operation_tag = ttlv.tag.clone(); let start_time = std::time::Instant::now(); - let result = dispatch_inner(kms, ttlv, user, &operation_tag).await; + let result = dispatch_inner(kms, ttlv, user, &operation_tag, roles, tenant_id).await; // Record metrics if enabled if let Some(ref metrics) = kms.metrics { @@ -102,10 +109,17 @@ async fn dispatch_inner( ttlv: TTLV, user: &str, operation_tag: &str, + roles: &[String], + tenant_id: Option<&str>, ) -> KResult { - // For operations where the request carries algorithm choices, validate them - // before executing any cryptographic action. - enforce_kmip_algorithm_policy_for_operation(&kms.params, operation_tag, &ttlv)?; + // RBAC pre-dispatch enforcement (Tier 1): check role/tenant policy for non-object ops. + enforce_rbac_pre_dispatch(kms, operation_tag, user, roles, tenant_id)?; + + // Legacy algorithm policy enforcement (bypassed when Rego evaluator is active, + // since algorithm checks are delegated to the policy engine). + if kms.rbac_evaluator().is_none() { + enforce_kmip_algorithm_policy_for_operation(&kms.params, operation_tag, &ttlv)?; + } Ok(match operation_tag { "Activate" => op!(ttlv, kms, user, Activate, activate, ActivateResponse), diff --git a/crate/server/src/core/operations/import.rs b/crate/server/src/core/operations/import.rs index fe8acf72cd..0e076f66b4 100644 --- a/crate/server/src/core/operations/import.rs +++ b/crate/server/src/core/operations/import.rs @@ -77,6 +77,8 @@ pub(crate) async fn import( None, &cosmian_kmip::kmip_2_1::KmipOperation::Create, kms, + &[], + None, ) .await?; diff --git a/crate/server/src/core/operations/key_ops/crypto_op.rs b/crate/server/src/core/operations/key_ops/crypto_op.rs index d3a85270b3..7179061f8c 100644 --- a/crate/server/src/core/operations/key_ops/crypto_op.rs +++ b/crate/server/src/core/operations/key_ops/crypto_op.rs @@ -401,12 +401,16 @@ pub(crate) async fn unwrap_and_enforce_policy( if !matches!(owm.object(), Object::Certificate { .. }) { owm.set_object(kms.get_unwrapped(owm.id(), owm.object(), user).await?); } - crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_retrieved_key( - &kms.params, - op_name, - owm.id(), - owm, - ) + // Legacy algorithm policy for retrieved keys (bypassed when Rego evaluator is active). + if kms.rbac_evaluator().is_none() { + crate::core::operations::algorithm_policy::enforce_kmip_algorithm_policy_for_retrieved_key( + &kms.params, + op_name, + owm.id(), + owm, + )?; + } + Ok(()) } // ─── UsageLimits helpers ───────────────────────────────────────────────────── diff --git a/crate/server/src/core/operations/register.rs b/crate/server/src/core/operations/register.rs index 363a9fe45f..3010d8c9d3 100644 --- a/crate/server/src/core/operations/register.rs +++ b/crate/server/src/core/operations/register.rs @@ -45,6 +45,8 @@ pub(crate) async fn register( None, &cosmian_kmip::kmip_2_1::KmipOperation::Create, kms, + &[], + None, ) .await?; diff --git a/crate/server/src/core/operations/rekey.rs b/crate/server/src/core/operations/rekey.rs index 005766ef7a..a20579bb95 100644 --- a/crate/server/src/core/operations/rekey.rs +++ b/crate/server/src/core/operations/rekey.rs @@ -49,7 +49,8 @@ pub(crate) async fn rekey( // ReKey creates a new replacement key — enforce privileged-user restriction if let Some(ref users) = privileged_users { - let has_permission = user_has_permission(owner, None, &KmipOperation::Create, kms).await?; + let has_permission = + user_has_permission(owner, None, &KmipOperation::Create, kms, &[], None).await?; if !has_permission && !users.iter().any(|u| u == owner) { kms_bail!(KmsError::Unauthorized( diff --git a/crate/server/src/core/operations/rekey_keypair.rs b/crate/server/src/core/operations/rekey_keypair.rs index 558f470f73..4d44a8cb79 100644 --- a/crate/server/src/core/operations/rekey_keypair.rs +++ b/crate/server/src/core/operations/rekey_keypair.rs @@ -68,7 +68,8 @@ pub(crate) async fn rekey_keypair( // ReKeyKeyPair creates a replacement key pair — enforce privileged-user restriction if let Some(ref users) = privileged_users { - let has_permission = user_has_permission(user, None, &KmipOperation::Create, kms).await?; + let has_permission = + user_has_permission(user, None, &KmipOperation::Create, kms, &[], None).await?; if !has_permission && !users.iter().any(|u| u == user) { kms_bail!(KmsError::Unauthorized( diff --git a/crate/server/src/core/rbac/audit.rs b/crate/server/src/core/rbac/audit.rs new file mode 100644 index 0000000000..f328ee53d8 --- /dev/null +++ b/crate/server/src/core/rbac/audit.rs @@ -0,0 +1,71 @@ +//! RBAC Audit Logger +//! +//! Emits structured `tracing::info!` events for every RBAC authorization decision. +//! Events are exported via the existing OTEL tracing pipeline. + +use tracing::info; + +use super::{evaluator::PolicyDecision, input_builder::PolicyInput}; + +/// Emit a structured audit event for an RBAC authorization decision. +/// +/// Both allow and deny decisions are logged for compliance traceability. +/// The `bundle_hash` links each decision to the specific policy version that produced it. +pub fn emit_rbac_audit(input: &PolicyInput, decision: &PolicyDecision, bundle_hash: &str) { + let resource_id = input.resource.as_ref().map_or("-", |r| r.id.as_str()); + let tenant_id = input.subject.tenant_id.as_deref().unwrap_or("-"); + let decision_str = if decision.allowed { "allow" } else { "deny" }; + let reason = decision.reason.as_deref().unwrap_or("-"); + + info!( + target: "kms::rbac::audit", + user = %input.subject.user_id, + operation = %input.operation.kmip_op, + resource_id = %resource_id, + tenant_id = %tenant_id, + decision = %decision_str, + reason = %reason, + bundle_hash = %bundle_hash, + "RBAC authorization decision" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::rbac::input_builder::{ + OperationContext, PolicyInput, RequestContext, Subject, + }; + + #[test] + fn test_emit_rbac_audit_does_not_panic() { + let input = PolicyInput::for_non_object_operation( + Subject { + user_id: "alice@test.com".to_owned(), + roles: vec!["operator".to_owned()], + tenant_id: Some("tenant-1".to_owned()), + is_privileged: false, + }, + RequestContext { + ip: None, + tls_subject: None, + user_agent: None, + }, + OperationContext { + kmip_op: "Create".to_owned(), + algorithm: None, + mode: None, + padding: None, + target_user: None, + grant_ops: None, + }, + ); + let decision = PolicyDecision { + allowed: true, + reason: Some("admin role".to_owned()), + }; + + // Should not panic + emit_rbac_audit(&input, &decision, "abc123"); + } +} diff --git a/crate/server/src/core/rbac/bundle_manager.rs b/crate/server/src/core/rbac/bundle_manager.rs new file mode 100644 index 0000000000..4afe0217d8 --- /dev/null +++ b/crate/server/src/core/rbac/bundle_manager.rs @@ -0,0 +1,261 @@ +//! Policy Bundle Manager +//! +//! Responsible for loading, validating, hashing, and hot-reloading Rego policy bundles. +//! Supports both local directory bundles (with file-watch) and remote URL bundles (with polling). + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use sha2::{Digest, Sha256}; + +use crate::{error::KmsError, result::KResult}; + +/// A single Rego file loaded from a policy bundle. +#[derive(Debug, Clone)] +pub struct RegoFile { + /// Filename (relative path within the bundle directory). + pub filename: String, + /// File content. + pub content: String, +} + +/// Computed bundle metadata after loading and validation. +#[derive(Debug, Clone)] +pub struct BundleMetadata { + /// Content-only SHA-256 hash (filenames excluded). + /// Computed over sorted per-file content hashes. + pub hash: String, + /// All Rego files in the bundle. + pub files: Vec, +} + +/// Loads a policy bundle from a local directory. +/// +/// The directory must contain at least one `.rego` file, and one of them must +/// define the `data.kms.authz.allow` rule (entry point is `authz.rego`). +/// +/// # Errors +/// Returns an error if the directory is missing, contains no `.rego` files, +/// or if no `authz.rego` entry point is found. +pub fn load_bundle_from_directory(path: &Path) -> KResult { + if !path.is_dir() { + return Err(KmsError::ServerError(format!( + "RBAC bundle path is not a directory: {}", + path.display() + ))); + } + + let mut files = Vec::new(); + for entry in fs::read_dir(path).map_err(|e| { + KmsError::ServerError(format!( + "Failed to read RBAC bundle directory {}: {e}", + path.display() + )) + })? { + let entry = entry + .map_err(|e| KmsError::ServerError(format!("Failed to read directory entry: {e}")))?; + let file_path = entry.path(); + if file_path.extension().is_some_and(|ext| ext == "rego") { + let filename = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let content = fs::read_to_string(&file_path).map_err(|e| { + KmsError::ServerError(format!( + "Failed to read Rego file {}: {e}", + file_path.display() + )) + })?; + files.push(RegoFile { filename, content }); + } + } + + if files.is_empty() { + return Err(KmsError::ServerError(format!( + "RBAC bundle directory contains no .rego files: {}", + path.display() + ))); + } + + // Verify entry point exists + if !files.iter().any(|f| f.filename == "authz.rego") { + return Err(KmsError::ServerError(format!( + "RBAC bundle is missing entry point 'authz.rego' in: {}", + path.display() + ))); + } + + let hash = compute_bundle_hash(&files); + Ok(BundleMetadata { hash, files }) +} + +/// Validates a policy bundle by compiling all `.rego` files with Regorus. +/// +/// This performs strict validation: if any file references an unsupported +/// built-in function, the bundle is rejected at load time. +/// +/// # Errors +/// Returns an error if any Rego file fails to compile. +pub fn validate_bundle(files: &[RegoFile]) -> KResult<()> { + let mut engine = regorus::Engine::new(); + + for file in files { + engine + .add_policy(file.filename.clone(), file.content.clone()) + .map_err(|e| { + KmsError::ServerError(format!( + "RBAC policy validation failed for '{}': {e}", + file.filename + )) + })?; + } + + Ok(()) +} + +/// Computes the content-only SHA-256 hash of a policy bundle. +/// +/// The hash is computed over sorted per-file content hashes (filenames excluded). +/// This means renaming a file without changing its content does not change the bundle hash, +/// preserving audit trail continuity. +pub fn compute_bundle_hash(files: &[RegoFile]) -> String { + // Hash each file's content individually + let mut content_hashes: Vec<[u8; 32]> = files + .iter() + .map(|f| { + let mut hasher = Sha256::new(); + hasher.update(f.content.as_bytes()); + hasher.finalize().into() + }) + .collect(); + + // Sort hashes for deterministic ordering regardless of file enumeration order + content_hashes.sort_unstable(); + + // Hash the sorted hashes together + let mut final_hasher = Sha256::new(); + for h in &content_hashes { + final_hasher.update(h); + } + + hex::encode(final_hasher.finalize()) +} + +/// Returns the disk-cache path for remote bundles. +/// +/// Remote bundles are cached under `/rbac_bundle_cache/` so the server +/// can start with the cached version if the remote is unreachable. +pub fn bundle_cache_path(data_dir: &Path) -> PathBuf { + data_dir.join("rbac_bundle_cache") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_compute_bundle_hash_is_filename_independent() { + let files_a = vec![ + RegoFile { + filename: "helpers.rego".to_owned(), + content: "package kms.helpers\nimport rego.v1\n".to_owned(), + }, + RegoFile { + filename: "authz.rego".to_owned(), + content: "package kms.authz\nimport rego.v1\nallow = true\n".to_owned(), + }, + ]; + + // Same content, different filenames + let files_b = vec![ + RegoFile { + filename: "utils.rego".to_owned(), + content: "package kms.helpers\nimport rego.v1\n".to_owned(), + }, + RegoFile { + filename: "main.rego".to_owned(), + content: "package kms.authz\nimport rego.v1\nallow = true\n".to_owned(), + }, + ]; + + assert_eq!(compute_bundle_hash(&files_a), compute_bundle_hash(&files_b)); + } + + #[test] + fn test_compute_bundle_hash_changes_with_content() { + let files_a = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: "package kms.authz\nallow = true\n".to_owned(), + }]; + + let files_b = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: "package kms.authz\nallow = false\n".to_owned(), + }]; + + assert_ne!(compute_bundle_hash(&files_a), compute_bundle_hash(&files_b)); + } + + #[test] + fn test_load_bundle_from_directory_success() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("authz.rego"), + "package kms.authz\nimport rego.v1\ndefault allow := false\n", + ) + .unwrap(); + + let result = load_bundle_from_directory(dir.path()); + assert!(result.is_ok()); + let meta = result.unwrap(); + assert_eq!(meta.files.len(), 1); + assert!(!meta.hash.is_empty()); + } + + #[test] + fn test_load_bundle_from_directory_missing_entry_point() { + let dir = TempDir::new().unwrap(); + fs::write( + dir.path().join("helpers.rego"), + "package kms.helpers\nimport rego.v1\n", + ) + .unwrap(); + + let result = load_bundle_from_directory(dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("authz.rego")); + } + + #[test] + fn test_load_bundle_from_directory_empty() { + let dir = TempDir::new().unwrap(); + let result = load_bundle_from_directory(dir.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no .rego files")); + } + + #[test] + fn test_validate_bundle_valid() { + let files = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: "package kms.authz\nimport rego.v1\ndefault allow := false\n".to_owned(), + }]; + assert!(validate_bundle(&files).is_ok()); + } + + #[test] + fn test_validate_bundle_invalid_syntax() { + let files = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: "this is not valid rego {{{{".to_owned(), + }]; + assert!(validate_bundle(&files).is_err()); + } +} diff --git a/crate/server/src/core/rbac/default_policies.rs b/crate/server/src/core/rbac/default_policies.rs new file mode 100644 index 0000000000..8f663d427b --- /dev/null +++ b/crate/server/src/core/rbac/default_policies.rs @@ -0,0 +1,287 @@ +//! Default embedded policy bundles. +//! +//! These policies are compiled into the binary via `include_str!` so the server +//! can start without an external bundle path in non-RBAC mode. + +use super::bundle_manager::RegoFile; + +/// The algorithm-only policy used when RBAC is disabled. +/// Only enforces the `data.kms.config.allowlists` algorithm restrictions. +const ALGORITHM_ONLY_POLICY: &str = include_str!("default_policies/algorithm_only.rego"); + +/// The full RBAC policy used as the default when RBAC is enabled +/// but no custom bundle is provided (or for reference/documentation). +const FULL_RBAC_POLICY: &str = include_str!("default_policies/authz.rego"); + +/// Returns the embedded algorithm-only policy as a `RegoFile` slice. +/// +/// Used when RBAC is disabled — provides algorithm enforcement without +/// roles, tenants, or ACL logic. +pub fn algorithm_only_bundle() -> Vec { + vec![RegoFile { + filename: "authz.rego".to_owned(), + content: ALGORITHM_ONLY_POLICY.to_owned(), + }] +} + +/// Returns the embedded full RBAC policy as a `RegoFile` slice. +/// +/// This is the default policy bundle for RBAC mode with the standard +/// role hierarchy: super-admin > admin > operator > auditor. +pub fn full_rbac_bundle() -> Vec { + vec![RegoFile { + filename: "authz.rego".to_owned(), + content: FULL_RBAC_POLICY.to_owned(), + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::rbac::{bundle_manager::validate_bundle, evaluator::PolicyEvaluator}; + use regorus::Value; + + #[test] + fn test_algorithm_only_bundle_compiles() { + let bundle = algorithm_only_bundle(); + assert!(validate_bundle(&bundle).is_ok()); + } + + #[test] + fn test_full_rbac_bundle_compiles() { + let bundle = full_rbac_bundle(); + assert!(validate_bundle(&bundle).is_ok()); + } + + #[test] + fn test_algorithm_only_allows_without_allowlist() { + let bundle = algorithm_only_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + let input = + Value::from_json_str(r#"{"operation": {"algorithm": "AES", "kmip_op": "Create"}}"#) + .unwrap(); + // No allowlists configured → allow + assert!(evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_algorithm_only_denies_unlisted_algorithm() { + let bundle = algorithm_only_bundle(); + let allowlists = r#"{"algorithms": ["AES", "RSA"]}"#; + let evaluator = PolicyEvaluator::new(&bundle, allowlists, "test".to_owned()).unwrap(); + + let input = Value::from_json_str( + r#"{"operation": {"algorithm": "ChaCha20", "kmip_op": "Create"}}"#, + ) + .unwrap(); + assert!(!evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_admin_allows() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "alice", "roles": ["admin"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Create", "algorithm": null}, + "resource": null, + "acl": null + }"#, + ) + .unwrap(); + assert!(evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_auditor_denied_create() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "bob", "roles": ["auditor"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Create", "algorithm": null}, + "resource": null, + "acl": null + }"#, + ) + .unwrap(); + assert!(!evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_tenant_isolation() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // Admin in tenant-1 accessing resource in tenant-2: denied + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "alice", "roles": ["admin"], "tenant_id": "tenant-1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Get", "algorithm": null}, + "resource": {"id": "key-1", "owner": "bob", "type": "SymmetricKey", "state": "Active", "tags": [], "tenant_id": "tenant-2"}, + "acl": {"is_owner": false, "granted_ops": []} + }"#, + ) + .unwrap(); + assert!(!evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_super_admin_cross_tenant() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // Super-admin can access any tenant + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "ops", "roles": ["super-admin"], "tenant_id": "ops-tenant", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Get", "algorithm": null}, + "resource": {"id": "key-1", "owner": "bob", "type": "SymmetricKey", "state": "Active", "tags": [], "tenant_id": "other-tenant"}, + "acl": {"is_owner": false, "granted_ops": ["Get"]} + }"#, + ) + .unwrap(); + assert!(evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_no_role_denied() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // User with no roles: denied even if owner + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "alice", "roles": [], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Get", "algorithm": null}, + "resource": {"id": "key-1", "owner": "alice", "type": "SymmetricKey", "state": "Active", "tags": [], "tenant_id": "t1"}, + "acl": {"is_owner": true, "granted_ops": []} + }"#, + ) + .unwrap(); + assert!(!evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_operator_can_encrypt() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "op1", "roles": ["operator"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Encrypt", "algorithm": null}, + "resource": {"id": "key-1", "owner": "op1", "type": "SymmetricKey", "state": "Active", "tags": [], "tenant_id": "t1"}, + "acl": {"is_owner": true, "granted_ops": []} + }"#, + ) + .unwrap(); + assert!(evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_operator_denied_without_access() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // Operator trying to access another user's object without grant + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "op1", "roles": ["operator"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Decrypt", "algorithm": null}, + "resource": {"id": "key-2", "owner": "other", "type": "SymmetricKey", "state": "Active", "tags": [], "tenant_id": "t1"}, + "acl": {"is_owner": false, "granted_ops": []} + }"#, + ) + .unwrap(); + assert!(!evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_operator_allowed_with_grant() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // Operator accessing another user's object WITH explicit grant + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "op1", "roles": ["operator"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Decrypt", "algorithm": null}, + "resource": {"id": "key-2", "owner": "other", "type": "SymmetricKey", "state": "Active", "tags": [], "tenant_id": "t1"}, + "acl": {"is_owner": false, "granted_ops": ["Decrypt"]} + }"#, + ) + .unwrap(); + assert!(evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_privileged_user_allowed() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // Privileged user with no explicit roles can still operate + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "svc-account", "roles": [], "tenant_id": "t1", "is_privileged": true}, + "request": {}, + "operation": {"kmip_op": "Create", "algorithm": null}, + "resource": null, + "acl": null + }"#, + ) + .unwrap(); + assert!(evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_algorithm_denied() { + let bundle = full_rbac_bundle(); + let allowlists = r#"{"algorithms": ["AES", "RSA"]}"#; + let evaluator = PolicyEvaluator::new(&bundle, allowlists, "test".to_owned()).unwrap(); + + // Admin with disallowed algorithm: denied + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "admin1", "roles": ["admin"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Create", "algorithm": "ChaCha20"}, + "resource": null, + "acl": null + }"#, + ) + .unwrap(); + assert!(!evaluator.evaluate(&input).allowed); + } + + #[test] + fn test_full_rbac_role_hierarchy_operator_inherits_auditor() { + let bundle = full_rbac_bundle(); + let evaluator = PolicyEvaluator::new(&bundle, "{}", "test".to_owned()).unwrap(); + + // Operator can do auditor operations (Locate) due to hierarchy + let input = Value::from_json_str( + r#"{ + "subject": {"user_id": "op1", "roles": ["operator"], "tenant_id": "t1", "is_privileged": false}, + "request": {}, + "operation": {"kmip_op": "Locate", "algorithm": null}, + "resource": null, + "acl": null + }"#, + ) + .unwrap(); + assert!(evaluator.evaluate(&input).allowed); + } +} diff --git a/crate/server/src/core/rbac/default_policies/README.md b/crate/server/src/core/rbac/default_policies/README.md new file mode 100644 index 0000000000..8ae9e48bf7 --- /dev/null +++ b/crate/server/src/core/rbac/default_policies/README.md @@ -0,0 +1,6 @@ +# Default Policies + +This directory contains the default Rego policy bundles embedded into the KMS binary. + +- `algorithm_only.rego` — Minimal policy for non-RBAC mode. Only enforces algorithm allowlists. +- `authz.rego` — Full RBAC policy with role hierarchy, tenant boundary, and ACL handling. diff --git a/crate/server/src/core/rbac/default_policies/algorithm_only.rego b/crate/server/src/core/rbac/default_policies/algorithm_only.rego new file mode 100644 index 0000000000..c5f7543e2e --- /dev/null +++ b/crate/server/src/core/rbac/default_policies/algorithm_only.rego @@ -0,0 +1,28 @@ +# Algorithm-only policy (embedded, used when RBAC is disabled) +# +# This minimal policy enforces only the algorithm allowlist configured in kms.toml. +# No role, tenant, or ACL checks are performed. +# Loaded via include_str! when no external bundle is configured. + +package kms.authz + +import rego.v1 + +default allow := false + +# Allow if no algorithm is specified (non-crypto operations) +allow if { + input.operation.algorithm == null +} + +# Allow if algorithm is in the configured allowlist +allow if { + input.operation.algorithm in data.kms.config.allowlists.algorithms +} + +# Allow if no algorithm allowlist is configured (unrestricted mode) +allow if { + not data.kms.config.allowlists.algorithms +} + +reason := "algorithm policy" diff --git a/crate/server/src/core/rbac/default_policies/authz.rego b/crate/server/src/core/rbac/default_policies/authz.rego new file mode 100644 index 0000000000..7571f4641b --- /dev/null +++ b/crate/server/src/core/rbac/default_policies/authz.rego @@ -0,0 +1,129 @@ +# Full RBAC authorization policy (default bundle) +# +# Implements NIST-compatible hierarchical RBAC: +# super-admin > admin > operator > auditor +# +# Features: +# - Role hierarchy with transitive inheritance +# - Tenant boundary enforcement +# - Algorithm allowlist checks via static data +# - Owner + explicit ACL grant handling +# - Create-grant privilege invariant (only admins can delegate Create) +# - Privileged users (break-glass via server config) + +package kms.authz + +import rego.v1 + +default allow := false + +# Operation sets per role +auditor_ops := {"Locate", "GetAttributes", "GetAttributeList"} + +operator_ops := auditor_ops | { + "Create", "CreateKeyPair", "Import", "Register", + "Encrypt", "Decrypt", "Sign", "SignatureVerify", + "MAC", "MACVerify", "Hash", + "Destroy", "Revoke", "Activate", "DeriveKey", "ReKey", "ReKeyKeyPair", + "Certify", "Validate", + "Get", "Export", + "SetAttribute", "ModifyAttribute", "AddAttribute", "DeleteAttribute", +} + +admin_ops := operator_ops | {"Grant", "RevokeAccess"} + +# Helper: check if user has a specific effective role (includes hierarchy) +has_role(r) if { + r in input.subject.roles +} + +has_role("admin") if { + "super-admin" in input.subject.roles +} + +has_role("operator") if { + has_role("admin") +} + +has_role("auditor") if { + has_role("operator") +} + +# Algorithm allowlist check +algorithm_allowed if { + input.operation.algorithm == null +} + +algorithm_allowed if { + not data.kms.config.allowlists.algorithms +} + +algorithm_allowed if { + input.operation.algorithm in data.kms.config.allowlists.algorithms +} + +# Tenant boundary: resource and subject must share tenant +same_tenant if { input.resource == null } +same_tenant if { input.resource.tenant_id == null } +same_tenant if { input.resource.tenant_id == input.subject.tenant_id } + +# Object accessibility: user must own or have explicit grant +object_accessible if { input.resource == null } +object_accessible if { input.acl == null } +object_accessible if { input.acl.is_owner } +object_accessible if { input.operation.kmip_op in input.acl.granted_ops } + +# === Allow rules === + +# Super-admin: all operations, no tenant boundary +allow if { + "super-admin" in input.subject.roles + algorithm_allowed +} + +# Admin: all operations, tenant-scoped +allow if { + has_role("admin") + same_tenant + algorithm_allowed +} + +# Operator: provisioning + crypto + lifecycle ops on accessible objects +allow if { + has_role("operator") + input.operation.kmip_op in operator_ops + same_tenant + algorithm_allowed + object_accessible +} + +# Auditor: metadata-only (no key material, no Export) +allow if { + has_role("auditor") + input.operation.kmip_op in auditor_ops + same_tenant +} + +# Privileged users: treated as admin by default policy +allow if { + input.subject.is_privileged + same_tenant + algorithm_allowed +} + +# Create-grant privilege: only admins can delegate Create +allow if { + input.operation.kmip_op == "Grant" + some op in input.operation.grant_ops + op == "Create" + has_role("admin") + same_tenant +} + +# Self-access: users can view their own access lists +allow if { + input.operation.kmip_op in {"ListAccessOwned", "ListAccessObtained", "CheckPermissions"} +} + +reason := "allowed by default RBAC policy" if { allow } +reason := "denied by default RBAC policy" if { not allow } diff --git a/crate/server/src/core/rbac/enforcement.rs b/crate/server/src/core/rbac/enforcement.rs new file mode 100644 index 0000000000..cb47b5e337 --- /dev/null +++ b/crate/server/src/core/rbac/enforcement.rs @@ -0,0 +1,116 @@ +//! RBAC Enforcement helpers for the dispatch layer. +//! +//! Provides the `enforce_rbac_pre_dispatch` function that checks whether +//! a KMIP operation is allowed before it executes. + +use cosmian_kms_server_database::reexport::cosmian_kmip::kmip_0::kmip_types::ErrorReason; +use cosmian_logger::trace; + +use crate::{ + core::{ + KMS, + rbac::{ + audit::emit_rbac_audit, + input_builder::{OperationContext, PolicyInput, RequestContext, Subject}, + }, + }, + error::KmsError, + result::KResult, +}; + +/// Operations exempt from RBAC enforcement (protocol-level, no key material). +const EXEMPT_OPERATIONS: &[&str] = &["DiscoverVersions", "Query"]; + +/// Pre-dispatch RBAC enforcement (Tier 1). +/// +/// For non-object operations (Create, `CreateKeyPair`, etc.), evaluates the policy +/// BEFORE the operation executes. Exempt operations (`DiscoverVersions`, `Query`) +/// always pass. +/// +/// Returns `Ok(())` if allowed, or `Err(Permission_Denied)` if denied. +pub(crate) fn enforce_rbac_pre_dispatch( + kms: &KMS, + operation_tag: &str, + user: &str, + roles: &[String], + tenant_id: Option<&str>, +) -> KResult<()> { + // Skip if no evaluator configured + let Some(evaluator) = kms.rbac_evaluator() else { + return Ok(()); + }; + + // Skip exempt operations + if EXEMPT_OPERATIONS.contains(&operation_tag) { + return Ok(()); + } + + // Skip if RBAC is not fully enabled (evaluator exists for algorithm-only mode, + // but we only enforce role/tenant checks when rbac.enabled is true) + if !kms.params.rbac.enabled { + return Ok(()); + } + + trace!( + "RBAC pre-dispatch: user={user}, op={operation_tag}, roles={roles:?}, tenant={tenant_id:?}" + ); + + // Determine if user is privileged (super-admin via config) + let is_privileged = kms + .params + .privileged_users + .as_ref() + .is_some_and(|pu| pu.iter().any(|p| p == user)); + let is_super_admin = kms.params.rbac.super_admins.iter().any(|sa| sa == user); + + let subject = Subject { + user_id: user.to_owned(), + roles: if is_super_admin { + let mut r = roles.to_vec(); + if !r.contains(&"super-admin".to_owned()) { + r.push("super-admin".to_owned()); + } + r + } else { + roles.to_vec() + }, + tenant_id: tenant_id.map(String::from), + is_privileged, + }; + + let input = PolicyInput::for_non_object_operation( + subject, + RequestContext { + ip: None, + tls_subject: None, + user_agent: None, + }, + OperationContext { + kmip_op: operation_tag.to_owned(), + algorithm: None, + mode: None, + padding: None, + target_user: None, + grant_ops: None, + }, + ); + + let regorus_input = input + .to_regorus_value() + .map_err(|e| KmsError::ServerError(format!("Failed to build RBAC input: {e}")))?; + + let decision = evaluator.evaluate(®orus_input); + let bundle_hash = evaluator.bundle_hash(); + + // Emit audit event + emit_rbac_audit(&input, &decision, &bundle_hash); + + if decision.allowed { + Ok(()) + } else { + Err(KmsError::Kmip21Error( + ErrorReason::Permission_Denied, + "authorization denied".to_owned(), + )) + } +} diff --git a/crate/server/src/core/rbac/evaluator.rs b/crate/server/src/core/rbac/evaluator.rs new file mode 100644 index 0000000000..c94d8bec71 --- /dev/null +++ b/crate/server/src/core/rbac/evaluator.rs @@ -0,0 +1,303 @@ +//! Policy Evaluator +//! +//! Wraps the Regorus policy source behind `ArcSwap` for atomic hot-reload. +//! Provides the `evaluate()` API that takes a policy input and returns a `PolicyDecision`. +//! +//! A fresh `regorus::Engine` is built per evaluation from the stored policy source. +//! This avoids `Send`/`Sync` issues with `regorus::Engine` while keeping the interface +//! thread-safe. For the small policy bundles used in KMS RBAC, engine construction +//! is sub-millisecond and acceptable overhead. + +use std::sync::Arc; + +use arc_swap::ArcSwap; +use regorus::Value; + +use super::bundle_manager::RegoFile; +use crate::{error::KmsError, result::KResult}; + +/// The decision path queried in the Rego policy. +const DECISION_PATH: &str = "data.kms.authz.allow"; +/// The reason path queried in the Rego policy (optional). +const REASON_PATH: &str = "data.kms.authz.reason"; + +/// Result of a policy evaluation. +#[derive(Debug, Clone)] +pub struct PolicyDecision { + /// Whether the request is allowed. + pub allowed: bool, + /// Optional reason string (for audit logging, not returned to client). + pub reason: Option, +} + +/// Immutable, `Send + Sync` policy source stored behind `ArcSwap`. +#[derive(Clone)] +struct PolicySource { + /// The Rego source files. + files: Vec, + /// Allowlists JSON (loaded as `data.kms.config.allowlists`). + allowlists_json: String, + /// Content-only bundle hash for audit logging. + bundle_hash: String, +} + +/// Thread-safe policy evaluator backed by Regorus. +/// +/// Uses `ArcSwap` for atomic policy source replacement during hot-reload. +/// Each evaluation builds a fresh `regorus::Engine` from the stored source — +/// this avoids `Send`/`Sync` constraints on the engine while keeping the +/// evaluator fully thread-safe. +pub struct PolicyEvaluator { + source: ArcSwap, +} + +impl PolicyEvaluator { + /// Create a new evaluator from a set of Rego files and optional allowlists data. + /// + /// Validates the bundle by compiling it once; subsequent evaluations rebuild from source. + /// + /// # Errors + /// Returns an error if any Rego file fails to compile or data cannot be loaded. + pub fn new( + rego_files: &[RegoFile], + allowlists_json: &str, + bundle_hash: String, + ) -> KResult { + // Validate by building the engine once + Self::build_engine(rego_files, allowlists_json)?; + + Ok(Self { + source: ArcSwap::new(Arc::new(PolicySource { + files: rego_files.to_vec(), + allowlists_json: allowlists_json.to_owned(), + bundle_hash, + })), + }) + } + + /// Evaluate the policy with the given input JSON value. + /// + /// Returns `PolicyDecision { allowed, reason }`. + /// On evaluation errors or engine build failures, returns `allowed: false` (fail-closed). + pub fn evaluate(&self, input: &Value) -> PolicyDecision { + let source = self.source.load(); + + let Ok(mut engine) = Self::build_engine(&source.files, &source.allowlists_json) else { + return PolicyDecision { + allowed: false, + reason: Some("engine build failed".to_owned()), + }; + }; + + engine.set_input(input.clone()); + + // Evaluate the allow decision (fail-closed on error) + let allowed = engine + .eval_rule(DECISION_PATH.to_owned()) + .is_ok_and(|value| value_to_bool(&value)); + + // Evaluate the reason (optional, best-effort) + let reason = engine + .eval_rule(REASON_PATH.to_owned()) + .ok() + .and_then(|v| value_to_string(&v)); + + PolicyDecision { allowed, reason } + } + + /// Returns the current bundle hash (for audit logging). + pub fn bundle_hash(&self) -> String { + self.source.load().bundle_hash.clone() + } + + /// Atomically reload the policy with a new bundle. + /// + /// Validates the new bundle by compiling it once, then swaps the source atomically. + /// In-flight evaluations that already loaded the old source will finish with the old policy. + /// + /// # Errors + /// Returns an error if the new bundle fails validation. The old policy remains active. + pub fn reload( + &self, + rego_files: &[RegoFile], + allowlists_json: &str, + bundle_hash: String, + ) -> KResult<()> { + // Validate before swapping + Self::build_engine(rego_files, allowlists_json)?; + + let new_source = Arc::new(PolicySource { + files: rego_files.to_vec(), + allowlists_json: allowlists_json.to_owned(), + bundle_hash, + }); + self.source.store(new_source); + Ok(()) + } + + /// Build a Regorus engine from Rego files and allowlists data. + fn build_engine(rego_files: &[RegoFile], allowlists_json: &str) -> KResult { + let mut engine = regorus::Engine::new(); + + for file in rego_files { + engine + .add_policy(file.filename.clone(), file.content.clone()) + .map_err(|e| { + KmsError::ServerError(format!( + "Failed to add Rego policy '{}': {e}", + file.filename + )) + })?; + } + + if !allowlists_json.is_empty() && allowlists_json != "{}" { + let data_json = format!(r#"{{"kms":{{"config":{{"allowlists":{allowlists_json}}}}}}}"#); + let data_value = Value::from_json_str(&data_json).map_err(|e| { + KmsError::ServerError(format!("Failed to parse allowlists JSON as OPA data: {e}")) + })?; + engine.add_data(data_value).map_err(|e| { + KmsError::ServerError(format!("Failed to load allowlists into Regorus: {e}")) + })?; + } + + Ok(engine) + } +} + +/// Extract a boolean from a Regorus Value. +const fn value_to_bool(value: &Value) -> bool { + match value { + Value::Bool(b) => *b, + _ => false, + } +} + +/// Extract a string from a Regorus Value. +fn value_to_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::rbac::bundle_manager::RegoFile; + + fn simple_allow_policy() -> Vec { + vec![RegoFile { + filename: "authz.rego".to_owned(), + content: r#" +package kms.authz + +import rego.v1 + +default allow := false + +allow if { + input.subject.roles[_] == "admin" +} + +reason := "admin role grants access" +"# + .to_owned(), + }] + } + + #[test] + fn test_evaluator_allows_admin() { + let evaluator = + PolicyEvaluator::new(&simple_allow_policy(), "{}", "test-hash".to_owned()).unwrap(); + + let input = Value::from_json_str( + r#"{"subject": {"user_id": "alice", "roles": ["admin"], "tenant_id": "t1"}}"#, + ) + .unwrap(); + + let decision = evaluator.evaluate(&input); + assert!(decision.allowed); + assert_eq!(decision.reason.as_deref(), Some("admin role grants access")); + } + + #[test] + fn test_evaluator_denies_non_admin() { + let evaluator = + PolicyEvaluator::new(&simple_allow_policy(), "{}", "test-hash".to_owned()).unwrap(); + + let input = Value::from_json_str( + r#"{"subject": {"user_id": "bob", "roles": ["auditor"], "tenant_id": "t1"}}"#, + ) + .unwrap(); + + let decision = evaluator.evaluate(&input); + assert!(!decision.allowed); + } + + #[test] + fn test_evaluator_fail_closed_on_empty_input() { + let evaluator = + PolicyEvaluator::new(&simple_allow_policy(), "{}", "test-hash".to_owned()).unwrap(); + + let input = Value::from_json_str(r#"{}"#).unwrap(); + let decision = evaluator.evaluate(&input); + assert!(!decision.allowed); + } + + #[test] + fn test_evaluator_reload_changes_behavior() { + let initial_policy = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: "package kms.authz\nimport rego.v1\ndefault allow := true\n".to_owned(), + }]; + + let evaluator = PolicyEvaluator::new(&initial_policy, "{}", "hash-v1".to_owned()).unwrap(); + + let input = Value::from_json_str(r#"{"subject": {"roles": []}}"#).unwrap(); + assert!(evaluator.evaluate(&input).allowed); + assert_eq!(evaluator.bundle_hash(), "hash-v1"); + + // Reload with a deny-all policy + let deny_policy = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: "package kms.authz\nimport rego.v1\ndefault allow := false\n".to_owned(), + }]; + evaluator + .reload(&deny_policy, "{}", "hash-v2".to_owned()) + .unwrap(); + + assert!(!evaluator.evaluate(&input).allowed); + assert_eq!(evaluator.bundle_hash(), "hash-v2"); + } + + #[test] + fn test_evaluator_with_allowlists_data() { + let policy = vec![RegoFile { + filename: "authz.rego".to_owned(), + content: r#" +package kms.authz + +import rego.v1 + +default allow := false + +allow if { + input.operation.algorithm in data.kms.config.allowlists.algorithms +} +"# + .to_owned(), + }]; + + let allowlists = r#"{"algorithms": ["AES", "RSA"]}"#; + let evaluator = PolicyEvaluator::new(&policy, allowlists, "test-hash".to_owned()).unwrap(); + + // AES is in the allowlist + let input_aes = Value::from_json_str(r#"{"operation": {"algorithm": "AES"}}"#).unwrap(); + assert!(evaluator.evaluate(&input_aes).allowed); + + // ChaCha20 is not in the allowlist + let input_chacha = + Value::from_json_str(r#"{"operation": {"algorithm": "ChaCha20"}}"#).unwrap(); + assert!(!evaluator.evaluate(&input_chacha).allowed); + } +} diff --git a/crate/server/src/core/rbac/input_builder.rs b/crate/server/src/core/rbac/input_builder.rs new file mode 100644 index 0000000000..0a367a89cb --- /dev/null +++ b/crate/server/src/core/rbac/input_builder.rs @@ -0,0 +1,255 @@ +//! Policy Input Builder +//! +//! Constructs the OPA input document (`PolicyInput`) from KMIP request context, +//! JWT claims, object metadata, and ACL state. This input is passed to the Regorus +//! evaluator for every authorization decision. + +use regorus::Value; +use serde::Serialize; + +/// Complete OPA input document matching the stable API contract in `CONTEXT.md`. +#[derive(Debug, Clone, Serialize)] +pub struct PolicyInput { + pub subject: Subject, + pub request: RequestContext, + pub operation: OperationContext, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub acl: Option, +} + +/// The authenticated subject (user) making the request. +#[derive(Debug, Clone, Serialize)] +pub struct Subject { + pub user_id: String, + pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + pub is_privileged: bool, +} + +/// HTTP request context (environment signals for policy decisions). +#[derive(Debug, Clone, Serialize)] +pub struct RequestContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls_subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_agent: Option, +} + +/// The KMIP operation being performed. +#[derive(Debug, Clone, Serialize)] +pub struct OperationContext { + pub kmip_op: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub algorithm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub padding: Option, + /// For Grant/Revoke access-management operations. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_user: Option, + /// For Grant operations: which ops are being granted. + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_ops: Option>, +} + +/// Resource (object) context for object-targeting operations. +#[derive(Debug, Clone, Serialize)] +pub struct ResourceContext { + pub id: String, + pub owner: String, + #[serde(rename = "type")] + pub object_type: String, + pub state: String, + pub tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, +} + +/// ACL context for object-targeting operations. +#[derive(Debug, Clone, Serialize)] +pub struct AclContext { + pub is_owner: bool, + pub granted_ops: Vec, +} + +impl PolicyInput { + /// Convert to a Regorus `Value` for policy evaluation. + /// + /// # Errors + /// Returns an error if JSON serialization fails (should not happen for well-formed inputs). + pub fn to_regorus_value(&self) -> Result { + let json = serde_json::to_string(self).map_err(|e| e.to_string())?; + Value::from_json_str(&json).map_err(|e| e.to_string()) + } + + /// Build a policy input for a non-object operation (Create, `CreateKeyPair`, etc.). + /// + /// `resource` and `acl` are `None` since no existing object is involved. + pub const fn for_non_object_operation( + subject: Subject, + request: RequestContext, + operation: OperationContext, + ) -> Self { + Self { + subject, + request, + operation, + resource: None, + acl: None, + } + } + + /// Build a policy input for an object-targeting operation (Get, Encrypt, Destroy, etc.). + pub const fn for_object_operation( + subject: Subject, + request: RequestContext, + operation: OperationContext, + resource: ResourceContext, + acl: AclContext, + ) -> Self { + Self { + subject, + request, + operation, + resource: Some(resource), + acl: Some(acl), + } + } + + /// Build a policy input for an access-management endpoint (Grant, Revoke). + pub const fn for_access_management( + subject: Subject, + request: RequestContext, + operation: OperationContext, + ) -> Self { + Self { + subject, + request, + operation, + resource: None, + acl: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_policy_input_serialization_non_object() { + let input = PolicyInput::for_non_object_operation( + Subject { + user_id: "alice@example.com".to_owned(), + roles: vec!["operator".to_owned()], + tenant_id: Some("acme-corp".to_owned()), + is_privileged: false, + }, + RequestContext { + ip: Some("192.168.1.1".to_owned()), + tls_subject: None, + user_agent: Some("ckms/1.0".to_owned()), + }, + OperationContext { + kmip_op: "Create".to_owned(), + algorithm: Some("AES".to_owned()), + mode: None, + padding: None, + target_user: None, + grant_ops: None, + }, + ); + + let value = input.to_regorus_value().unwrap(); + let json = serde_json::to_value(&input).unwrap(); + + // Verify structure + assert_eq!(json["subject"]["user_id"], "alice@example.com"); + assert_eq!(json["subject"]["roles"][0], "operator"); + assert_eq!(json["operation"]["kmip_op"], "Create"); + assert_eq!(json["operation"]["algorithm"], "AES"); + assert!(json.get("resource").is_none()); + assert!(json.get("acl").is_none()); + + // Verify Regorus value is valid + assert!(!format!("{value:?}").is_empty()); + } + + #[test] + fn test_policy_input_serialization_object_op() { + let input = PolicyInput::for_object_operation( + Subject { + user_id: "bob@example.com".to_owned(), + roles: vec!["admin".to_owned()], + tenant_id: Some("acme-corp".to_owned()), + is_privileged: true, + }, + RequestContext { + ip: None, + tls_subject: Some("CN=bob,O=Acme".to_owned()), + user_agent: None, + }, + OperationContext { + kmip_op: "Decrypt".to_owned(), + algorithm: Some("RSA".to_owned()), + mode: None, + padding: Some("OAEP".to_owned()), + target_user: None, + grant_ops: None, + }, + ResourceContext { + id: "key-123".to_owned(), + owner: "alice@example.com".to_owned(), + object_type: "PrivateKey".to_owned(), + state: "Active".to_owned(), + tags: vec!["env:prod".to_owned()], + tenant_id: Some("acme-corp".to_owned()), + }, + AclContext { + is_owner: false, + granted_ops: vec!["Decrypt".to_owned(), "Get".to_owned()], + }, + ); + + let json = serde_json::to_value(&input).unwrap(); + assert_eq!(json["resource"]["id"], "key-123"); + assert_eq!(json["acl"]["is_owner"], false); + assert_eq!(json["acl"]["granted_ops"][0], "Decrypt"); + assert_eq!(json["subject"]["is_privileged"], true); + } + + #[test] + fn test_policy_input_access_management() { + let input = PolicyInput::for_access_management( + Subject { + user_id: "alice@example.com".to_owned(), + roles: vec!["operator".to_owned()], + tenant_id: Some("acme-corp".to_owned()), + is_privileged: false, + }, + RequestContext { + ip: None, + tls_subject: None, + user_agent: None, + }, + OperationContext { + kmip_op: "Grant".to_owned(), + algorithm: None, + mode: None, + padding: None, + target_user: Some("bob@example.com".to_owned()), + grant_ops: Some(vec!["Encrypt".to_owned(), "Decrypt".to_owned()]), + }, + ); + + let json = serde_json::to_value(&input).unwrap(); + assert_eq!(json["operation"]["target_user"], "bob@example.com"); + assert_eq!(json["operation"]["grant_ops"][0], "Encrypt"); + } +} diff --git a/crate/server/src/core/rbac/mod.rs b/crate/server/src/core/rbac/mod.rs new file mode 100644 index 0000000000..e6dd91b72c --- /dev/null +++ b/crate/server/src/core/rbac/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod audit; +pub(crate) mod bundle_manager; +pub(crate) mod default_policies; +pub(crate) mod enforcement; +pub(crate) mod evaluator; +pub(crate) mod input_builder; +pub(crate) mod remote_poller; +pub(crate) mod watcher; diff --git a/crate/server/src/core/rbac/remote_poller.rs b/crate/server/src/core/rbac/remote_poller.rs new file mode 100644 index 0000000000..d2183f082e --- /dev/null +++ b/crate/server/src/core/rbac/remote_poller.rs @@ -0,0 +1,175 @@ +//! Remote policy bundle polling. +//! +//! When `rbac_bundle_url` is configured, this module periodically downloads +//! the policy archive, unpacks it to a local cache directory, validates it, +//! and reloads the evaluator. On sustained unavailability, the cached bundle +//! is used with a warning (silent staleness — operator monitors via logs). + +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use cosmian_logger::{info, warn}; + +use super::{ + bundle_manager::{compute_bundle_hash, load_bundle_from_directory, validate_bundle}, + evaluator::PolicyEvaluator, +}; + +/// Spawn a periodic polling task that fetches the remote bundle. +/// +/// # Arguments +/// * `bundle_url` — URL to fetch the policy archive from. +/// * `cache_dir` — Local directory to cache/unpack the downloaded bundle. +/// * `poll_interval_secs` — Seconds between polling attempts. +/// * `evaluator` — The policy evaluator to reload on successful fetch. +/// * `allowlists_json` — Serialized allowlists data for the engine. +pub fn spawn_remote_poller( + bundle_url: String, + cache_dir: PathBuf, + poll_interval_secs: u64, + evaluator: Arc, + allowlists_json: String, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + run_poller( + &bundle_url, + &cache_dir, + poll_interval_secs, + &evaluator, + &allowlists_json, + ) + .await; + }) +} + +/// Internal polling loop. +async fn run_poller( + bundle_url: &str, + cache_dir: &Path, + poll_interval_secs: u64, + evaluator: &PolicyEvaluator, + allowlists_json: &str, +) { + let interval = Duration::from_secs(poll_interval_secs); + + info!("RBAC remote bundle poller started (url={bundle_url}, interval={poll_interval_secs}s)"); + + loop { + tokio::time::sleep(interval).await; + + match fetch_and_reload(bundle_url, cache_dir, evaluator, allowlists_json).await { + Ok(Some(hash)) => { + info!("RBAC remote bundle updated successfully (hash: {hash})"); + } + Ok(None) => { + // Hash unchanged — no reload needed + } + Err(e) => { + warn!("RBAC remote bundle poll failed (keeping cached policy): {e}"); + } + } + } +} + +/// Fetch the remote bundle, unpack to cache, validate, and reload. +/// +/// Returns `Ok(Some(hash))` if a new bundle was loaded, `Ok(None)` if unchanged, +/// or `Err` if the fetch/validation failed. +async fn fetch_and_reload( + bundle_url: &str, + cache_dir: &Path, + evaluator: &PolicyEvaluator, + allowlists_json: &str, +) -> Result, String> { + // Download the bundle archive + let response = reqwest::get(bundle_url) + .await + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if !response.status().is_success() { + return Err(format!("Remote returned HTTP {}", response.status())); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read response body: {e}"))?; + + // Ensure cache directory exists + fs::create_dir_all(cache_dir) + .map_err(|e| format!("Failed to create cache dir {}: {e}", cache_dir.display()))?; + + // Unpack: for simplicity, treat the response as a tar.gz or as raw .rego files. + // If it's a single .rego file, write directly. If tar.gz, unpack. + // For this implementation, we support a simple directory-of-files approach: + // the remote serves a JSON manifest or we write the body as authz.rego. + unpack_bundle(&bytes, cache_dir)?; + + // Load from the cache directory (same as local bundle loading) + let metadata = load_bundle_from_directory(cache_dir).map_err(|e| e.to_string())?; + validate_bundle(&metadata.files).map_err(|e| e.to_string())?; + + let new_hash = compute_bundle_hash(&metadata.files); + + // Skip if hash unchanged + if new_hash == evaluator.bundle_hash() { + return Ok(None); + } + + evaluator + .reload(&metadata.files, allowlists_json, new_hash.clone()) + .map_err(|e| e.to_string())?; + + Ok(Some(new_hash)) +} + +/// Unpack bundle bytes into the cache directory. +/// +/// The remote server should serve the bundle as a single `.rego` file (written as `authz.rego`) +/// or as a JSON object mapping filenames to content: +/// ```json +/// {"authz.rego": "package kms.authz\n...", "helpers.rego": "package kms.helpers\n..."} +/// ``` +fn unpack_bundle(bytes: &[u8], cache_dir: &Path) -> Result<(), String> { + let content = std::str::from_utf8(bytes) + .map_err(|e| format!("Bundle content is not valid UTF-8: {e}"))?; + + // Try parsing as JSON manifest (multi-file bundle) + if let Ok(manifest) = serde_json::from_str::>(content) + { + // Clear existing .rego files before writing new ones + if let Ok(entries) = fs::read_dir(cache_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("rego")) + { + drop(fs::remove_file(&path)); + } + } + } + + for (filename, file_content) in &manifest { + if Path::new(filename) + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("rego")) + { + let dest = cache_dir.join(filename); + fs::write(&dest, file_content) + .map_err(|e| format!("Failed to write {filename}: {e}"))?; + } + } + } else { + // Treat as raw .rego content (single-file bundle) + let dest = cache_dir.join("authz.rego"); + fs::write(&dest, content) + .map_err(|e| format!("Failed to write bundle to {}: {e}", dest.display()))?; + } + + Ok(()) +} diff --git a/crate/server/src/core/rbac/watcher.rs b/crate/server/src/core/rbac/watcher.rs new file mode 100644 index 0000000000..643ca50aed --- /dev/null +++ b/crate/server/src/core/rbac/watcher.rs @@ -0,0 +1,130 @@ +//! Hot-reload watcher for local policy bundles. +//! +//! Uses the `notify` crate to watch the bundle directory for file changes. +//! On change: reloads all `.rego` files, validates, computes new hash, +//! and atomically swaps the evaluator's policy source via `PolicyEvaluator::reload()`. +//! Invalid bundles are rejected with a warning — the old policy remains active. + +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use cosmian_logger::{error, info, warn}; +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher, event::ModifyKind}; +use tokio::sync::mpsc; + +use super::{ + bundle_manager::{compute_bundle_hash, load_bundle_from_directory, validate_bundle}, + evaluator::PolicyEvaluator, +}; + +/// Debounce interval to avoid reloading multiple times for rapid file changes. +const DEBOUNCE_MS: u64 = 500; + +/// Spawn a file watcher task that monitors the bundle directory and reloads +/// the policy evaluator when `.rego` files change. +/// +/// Returns a `JoinHandle` for the watcher task. The task runs until the +/// server shuts down (it holds an `Arc` to the evaluator). +/// +/// # Arguments +/// * `bundle_path` — Directory containing `.rego` files to watch. +/// * `evaluator` — The policy evaluator to reload on changes. +/// * `allowlists_json` — Serialized allowlists data to pass to the new engine. +pub fn spawn_bundle_watcher( + bundle_path: PathBuf, + evaluator: Arc, + allowlists_json: String, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + if let Err(e) = run_watcher(&bundle_path, &evaluator, &allowlists_json).await { + error!("RBAC bundle watcher failed: {e}"); + } + }) +} + +/// Internal watcher loop. +async fn run_watcher( + bundle_path: &Path, + evaluator: &PolicyEvaluator, + allowlists_json: &str, +) -> Result<(), Box> { + let (tx, mut rx) = mpsc::channel(16); + + // Create the filesystem watcher + let mut watcher = RecommendedWatcher::new( + move |result: Result| { + if let Ok(event) = result { + // Only trigger on file modifications and creations + let dominated_by_rego = event + .paths + .iter() + .any(|p| p.extension().is_some_and(|ext| ext == "rego")); + let is_relevant = dominated_by_rego + && matches!( + event.kind, + notify::EventKind::Modify(ModifyKind::Data(_)) + | notify::EventKind::Create(_) + | notify::EventKind::Remove(_) + ); + if is_relevant { + let _ = tx.blocking_send(()); + } + } + }, + notify::Config::default(), + )?; + + watcher.watch(bundle_path, RecursiveMode::NonRecursive)?; + info!("RBAC bundle watcher started on: {}", bundle_path.display()); + + // Debounced reload loop + loop { + // Wait for a change notification + if rx.recv().await.is_none() { + break; // Channel closed, shutdown + } + + // Debounce: drain any additional events within the window + tokio::time::sleep(Duration::from_millis(DEBOUNCE_MS)).await; + while rx.try_recv().is_ok() {} + + // Attempt reload + info!("RBAC bundle change detected, reloading..."); + match reload_bundle(bundle_path, evaluator, allowlists_json) { + Ok(hash) => { + info!("RBAC bundle reloaded successfully (hash: {hash})"); + } + Err(e) => { + warn!("RBAC bundle reload failed (keeping old policy): {e}"); + } + } + } + + Ok(()) +} + +/// Load, validate, and reload the bundle into the evaluator. +fn reload_bundle( + bundle_path: &Path, + evaluator: &PolicyEvaluator, + allowlists_json: &str, +) -> Result { + let metadata = load_bundle_from_directory(bundle_path).map_err(|e| e.to_string())?; + validate_bundle(&metadata.files).map_err(|e| e.to_string())?; + + let new_hash = compute_bundle_hash(&metadata.files); + + // Skip reload if hash hasn't changed (e.g., editor save without modifications) + if new_hash == evaluator.bundle_hash() { + return Ok(new_hash); + } + + evaluator + .reload(&metadata.files, allowlists_json, new_hash.clone()) + .map_err(|e| e.to_string())?; + + Ok(new_hash) +} diff --git a/crate/server/src/core/retrieve_object_utils.rs b/crate/server/src/core/retrieve_object_utils.rs index e87f695a98..7f0647b2fc 100644 --- a/crate/server/src/core/retrieve_object_utils.rs +++ b/crate/server/src/core/retrieve_object_utils.rs @@ -9,7 +9,16 @@ use cosmian_kms_server_database::reexport::{ use cosmian_logger::{trace, warn}; use crate::{ - core::{KMS, uid_utils::has_prefix}, + core::{ + KMS, + rbac::{ + audit::emit_rbac_audit, + input_builder::{ + AclContext, OperationContext, PolicyInput, RequestContext, ResourceContext, Subject, + }, + }, + uid_utils::has_prefix, + }, error::KmsError, result::KResult, }; @@ -84,7 +93,7 @@ pub(crate) async fn retrieve_object_for_operation( continue; } - if user_has_permission(user, Some(owm), &operation_type, kms).await? { + if user_has_permission(user, Some(owm), &operation_type, kms, &[], None).await? { trace!( "User {user} has permission for operation {operation_type:?} on object {}", owm.id() @@ -197,14 +206,20 @@ pub(crate) async fn retrieve_object_for_operation( } /// Check if a user has permission to perform an operation on an object. -/// If the user is the owner of the object, it will always return true. -/// For non-HSM objects, having the `Get` permission implies all other operations. -/// For HSM objects, each operation must be explicitly granted (no `Get` wildcard). +/// +/// When RBAC mode is active, delegates to the Regorus policy evaluator. +/// When RBAC is disabled, uses the legacy ownership + ACL grant model: +/// - If the user is the owner of the object, it will always return true. +/// - For non-HSM objects, having the `Get` permission implies all other operations. +/// - For HSM objects, each operation must be explicitly granted (no `Get` wildcard). +/// /// # Arguments /// * `user` - The user to check the permission for. /// * `owm` - The object to check the permission on. /// * `operation_type` - The operation to check the permission for. /// * `kms` - The KMS instance. +/// * `roles` - RBAC roles extracted from JWT (empty when RBAC disabled). +/// * `tenant_id` - Tenant ID extracted from JWT (None when RBAC disabled). /// # Returns /// * `Ok(true)` if the user has permission to perform the operation on the object. /// * `Ok(false)` if the user does not have permission to perform the operation on the object. @@ -213,6 +228,34 @@ pub(crate) async fn user_has_permission( owm: Option<&ObjectWithMetadata>, operation_type: &KmipOperation, kms: &KMS, + roles: &[String], + tenant_id: Option<&str>, +) -> KResult { + // When RBAC is fully enabled, delegate to the policy evaluator + if kms.params.rbac.enabled { + if let Some(evaluator) = kms.rbac_evaluator() { + return rbac_check_object_access( + user, + owm, + operation_type, + kms, + evaluator, + roles, + tenant_id, + ); + } + } + + // Legacy path (RBAC disabled) + legacy_user_has_permission(user, owm, operation_type, kms).await +} + +/// Legacy ACL-based permission check (used when RBAC is disabled). +async fn legacy_user_has_permission( + user: &str, + owm: Option<&ObjectWithMetadata>, + operation_type: &KmipOperation, + kms: &KMS, ) -> KResult { let id = match owm { Some(object) if user == object.owner() => return Ok(true), @@ -261,3 +304,94 @@ pub(crate) async fn user_has_permission( Ok(permissions.contains(operation_type) || permissions.contains(&KmipOperation::Get)) } + +/// RBAC policy-based object access check (Tier 2). +/// +/// Builds a full `PolicyInput` with resource context and ACL state, +/// then evaluates the Regorus policy. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn rbac_check_object_access( + user: &str, + owm: Option<&ObjectWithMetadata>, + operation_type: &KmipOperation, + kms: &KMS, + evaluator: &crate::core::rbac::evaluator::PolicyEvaluator, + roles: &[String], + tenant_id: Option<&str>, +) -> KResult { + // Determine if user is privileged or super-admin + let is_privileged = kms + .params + .privileged_users + .as_ref() + .is_some_and(|pu| pu.iter().any(|p| p == user)); + let is_super_admin = kms.params.rbac.super_admins.iter().any(|sa| sa == user); + + let mut effective_roles = roles.to_vec(); + if is_super_admin && !effective_roles.contains(&"super-admin".to_owned()) { + effective_roles.push("super-admin".to_owned()); + } + + let subject = Subject { + user_id: user.to_owned(), + roles: effective_roles, + tenant_id: tenant_id.map(String::from), + is_privileged, + }; + + // Build resource + ACL context from ObjectWithMetadata + let (resource, acl) = owm.map_or((None, None), |obj| { + let is_owner = user == obj.owner(); + let tags: Vec = obj + .attributes() + .get_tags(&kms.params.vendor_identification) + .into_iter() + .collect(); + + let resource = ResourceContext { + id: obj.id().to_owned(), + owner: obj.owner().to_owned(), + object_type: format!("{:?}", obj.object().object_type()), + state: format!("{:?}", obj.state()), + tags, + tenant_id: None, // TODO: read from tenant_id column once DB layer exposes it + }; + + let acl = AclContext { + is_owner, + granted_ops: Vec::new(), + }; + + (Some(resource), Some(acl)) + }); + + let input = PolicyInput { + subject, + request: RequestContext { + ip: None, + tls_subject: None, + user_agent: None, + }, + operation: OperationContext { + kmip_op: format!("{operation_type:?}"), + algorithm: None, + mode: None, + padding: None, + target_user: None, + grant_ops: None, + }, + resource, + acl, + }; + + let regorus_input = input + .to_regorus_value() + .map_err(|e| KmsError::ServerError(format!("Failed to build RBAC input: {e}")))?; + + let decision = evaluator.evaluate(®orus_input); + let bundle_hash = evaluator.bundle_hash(); + + emit_rbac_audit(&input, &decision, &bundle_hash); + + Ok(decision.allowed) +} diff --git a/crate/server/src/middlewares/api_token/api_token_middleware.rs b/crate/server/src/middlewares/api_token/api_token_middleware.rs index 4e111b6d69..c0b5b0792b 100644 --- a/crate/server/src/middlewares/api_token/api_token_middleware.rs +++ b/crate/server/src/middlewares/api_token/api_token_middleware.rs @@ -129,6 +129,7 @@ where // and proceed with the request req.extensions_mut().insert(AuthenticatedUser { username: kms_server.params.default_username.clone(), + rbac_context: None, }); } Err(e) => { diff --git a/crate/server/src/middlewares/ensure_auth.rs b/crate/server/src/middlewares/ensure_auth.rs index 33fbd82aed..68e8977ae7 100644 --- a/crate/server/src/middlewares/ensure_auth.rs +++ b/crate/server/src/middlewares/ensure_auth.rs @@ -146,6 +146,7 @@ where // Insert the default username as the authenticated user req.extensions_mut().insert(AuthenticatedUser { username: self.kms_server.params.default_username.clone(), + rbac_context: None, }); Box::pin(async move { diff --git a/crate/server/src/middlewares/jwt/jwt_config.rs b/crate/server/src/middlewares/jwt/jwt_config.rs index 22125db2ac..5a440ec7eb 100644 --- a/crate/server/src/middlewares/jwt/jwt_config.rs +++ b/crate/server/src/middlewares/jwt/jwt_config.rs @@ -130,6 +130,57 @@ pub(crate) struct UserClaim { pub email_type: Option, // Google CSE pub google_email: Option, + /// All additional claims not captured by explicit fields above. + /// Used for dynamic RBAC claim extraction (roles, `tenant_id`, etc.) + #[serde(flatten)] + pub extra_claims: Option>, +} + +impl UserClaim { + /// Extract a value at a dot-notation path from the JWT claims. + /// + /// Supports nested paths like `realm_access.roles` or simple top-level like `roles`. + /// Returns `None` if the path doesn't exist or the claim map is not present. + #[allow(dead_code)] + pub(crate) fn get_claim_at_path(&self, path: &str) -> Option<&serde_json::Value> { + let claims = self.extra_claims.as_ref()?; + let segments: Vec<&str> = path.split('.').collect(); + + let mut current: &serde_json::Value = claims.get(*segments.first()?)?; + + for segment in segments.get(1..).unwrap_or_default() { + current = current.as_object()?.get(*segment)?; + } + + Some(current) + } + + /// Extract roles from the configured claim path. + /// + /// Expects the claim value to be a JSON array of strings. + /// Returns an empty Vec if the claim is missing or not an array. + #[allow(dead_code)] + pub(crate) fn extract_roles(&self, claim_path: &str) -> Vec { + self.get_claim_at_path(claim_path) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } + + /// Extract tenant ID from the configured claim path. + /// + /// Expects the claim value to be a string. + /// Returns `None` if the claim is missing or not a string. + #[allow(dead_code)] + pub(crate) fn extract_tenant_id(&self, claim_path: &str) -> Option { + self.get_claim_at_path(claim_path) + .and_then(|v| v.as_str()) + .map(String::from) + } } #[derive(Debug, Deserialize)] diff --git a/crate/server/src/middlewares/jwt/jwt_middleware.rs b/crate/server/src/middlewares/jwt/jwt_middleware.rs index a5fbcf9d2c..20ba014c13 100644 --- a/crate/server/src/middlewares/jwt/jwt_middleware.rs +++ b/crate/server/src/middlewares/jwt/jwt_middleware.rs @@ -133,10 +133,13 @@ where ); } else { match handle_jwt(jwt_configurations, &req).await { - Ok(auth_claim) => { + Ok((auth_user, user_claim)) => { // Authentication successful, insert the claim into request extensions - // and proceed with the request - req.extensions_mut().insert(auth_claim); + req.extensions_mut().insert(auth_user); + // Attach full UserClaim for downstream RBAC claim extraction + if let Some(claim) = user_claim { + req.extensions_mut().insert(claim); + } } Err(e) => { debug!("JWT authentication failed: {e:?}"); diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index 2c3180a7ec..89230844b3 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -55,12 +55,12 @@ fn extract_user_claim(configs: &[JwtConfig], token: &str) -> Result))` - Auth successful; claim may be attached for RBAC /// * `Err(KmsError)` - Authentication failed pub(super) async fn handle_jwt( configs: Arc>, req: &ServiceRequest, -) -> KResult { +) -> KResult<(AuthenticatedUser, Option)> { trace!("JWT Authentication..."); // Extract identity from either the Identity service or the Authorization header @@ -94,20 +94,25 @@ pub(super) async fn handle_jwt( } // Process the validation result and extract the email claim - match private_claim.map(|user_claim| user_claim.email) { - Ok(Some(email)) => { - // Authentication successful with valid email - debug!("JWT Access granted to {email}!"); - Ok(AuthenticatedUser { username: email }) - } - Ok(None) => { - // JWT is valid but missing the required email claim — log as WARN for audit trail - warn!( - "{:?} {} 401 unauthorized, no email in JWT", - req.method(), - req.path() - ); - Err(KmsError::InvalidRequest("No email in JWT".to_owned())) + match private_claim { + Ok(user_claim) => { + if let Some(ref email) = user_claim.email { + // Authentication successful with valid email + debug!("JWT Access granted to {email}!"); + let auth_user = AuthenticatedUser { + username: email.clone(), + rbac_context: None, // Populated later by RBAC enforcement layer + }; + Ok((auth_user, Some(user_claim))) + } else { + // JWT is valid but missing the required email claim — log as WARN for audit trail + warn!( + "{:?} {} 401 unauthorized, no email in JWT", + req.method(), + req.path() + ); + Err(KmsError::InvalidRequest("No email in JWT".to_owned())) + } } Err(jwt_log_errors) => { // JWT validation failed — log at WARN so auth failures appear in production logs diff --git a/crate/server/src/middlewares/mod.rs b/crate/server/src/middlewares/mod.rs index 587f9a2ea6..751fc1de38 100644 --- a/crate/server/src/middlewares/mod.rs +++ b/crate/server/src/middlewares/mod.rs @@ -21,4 +21,20 @@ pub(crate) use rate_limiter::{RateLimiterConfig, RateLimiterMiddleware}; pub(crate) struct AuthenticatedUser { /// The authenticated username pub username: String, + /// RBAC context extracted from JWT claims (populated when RBAC is configured). + #[allow(dead_code)] + pub rbac_context: Option, +} + +/// RBAC-specific user context extracted from JWT claims. +/// +/// Populated during JWT authentication when RBAC config is present. +/// Carried through the request lifecycle for policy evaluation. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub(crate) struct RbacUserContext { + /// Roles extracted from the configured claim path. + pub roles: Vec, + /// Tenant ID extracted from the configured claim path. + pub tenant_id: Option, } diff --git a/crate/server/src/middlewares/tls_auth.rs b/crate/server/src/middlewares/tls_auth.rs index fcc7055628..c7427dfa16 100644 --- a/crate/server/src/middlewares/tls_auth.rs +++ b/crate/server/src/middlewares/tls_auth.rs @@ -164,7 +164,10 @@ fn tls_auth(req: &ServiceRequest) -> KResult { ); } trace!("Client certificate common name: {}", username); - Ok(AuthenticatedUser { username }) + Ok(AuthenticatedUser { + username, + rbac_context: None, + }) } Err(e) => kms_bail!("Client certificate common name is not UTF-8: {}", e), }, diff --git a/crate/server/src/routes/access.rs b/crate/server/src/routes/access.rs index ec51e27aa7..6d4c47648b 100644 --- a/crate/server/src/routes/access.rs +++ b/crate/server/src/routes/access.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use actix_web::{ - HttpRequest, get, post, + HttpMessage, HttpRequest, get, post, web::{self, Data, Json, Path}, }; use cosmian_kms_access::access::{ @@ -9,13 +9,22 @@ use cosmian_kms_access::access::{ PrivilegedAccessResponse, SuccessResponse, UserAccessResponse, }; use cosmian_kms_server_database::reexport::cosmian_kmip::{ - self, kmip_2_1::kmip_types::UniqueIdentifier, + self, kmip_0::kmip_types::ErrorReason, kmip_2_1::kmip_types::UniqueIdentifier, }; use cosmian_logger::{debug, info}; use serde::Serialize; use crate::{ - core::{KMS, retrieve_object_utils::user_has_permission}, + core::{ + KMS, + rbac::{ + audit::emit_rbac_audit, + input_builder::{OperationContext, PolicyInput, RequestContext, Subject}, + }, + retrieve_object_utils::user_has_permission, + }, + error::KmsError, + middlewares::UserClaim, result::KResult, }; @@ -111,6 +120,9 @@ pub(crate) async fn grant_access( "POST /access/grant" ); + // RBAC Tier 3: enforce policy for access-management endpoints + enforce_rbac_access_endpoint(&req, &kms, &user, "Grant", Some(&access))?; + kms.grant_access(&access, &user, privileged_users.as_ref().clone()) .await?; debug!("Access granted on {}", access.user_id); @@ -139,6 +151,9 @@ pub(crate) async fn revoke_access( "POST /access/revoke" ); + // RBAC Tier 3: enforce policy for access-management endpoints + enforce_rbac_access_endpoint(&req, &kms, &user, "RevokeAccess", Some(&access))?; + kms.revoke_access(&access, &user, privileged_users.as_ref().clone()) .await?; debug!("Access revoke for {}", access.user_id); @@ -168,6 +183,8 @@ pub(crate) async fn get_create_access( None, &cosmian_kmip::kmip_2_1::KmipOperation::Create, &kms, + &[], + None, ) .await? } @@ -198,3 +215,99 @@ pub(crate) async fn get_privileged_access( has_privileged_access, })) } + +/// RBAC Tier 3 enforcement for access-management endpoints. +/// +/// Evaluates the Regorus policy with an access-management operation context. +/// Only enforced when RBAC is fully enabled; otherwise a no-op. +fn enforce_rbac_access_endpoint( + req: &HttpRequest, + kms: &KMS, + user: &str, + operation: &str, + access: Option<&Access>, +) -> KResult<()> { + // Skip if RBAC not enabled + if !kms.params.rbac.enabled { + return Ok(()); + } + + let Some(evaluator) = kms.rbac_evaluator() else { + return Ok(()); + }; + + // Extract roles/tenant from JWT claims + let extensions = req.extensions(); + let (roles, tenant_id) = extensions.get::().map_or_else( + || (Vec::new(), None), + |claim| { + ( + claim.extract_roles(&kms.params.rbac.role_claim), + claim.extract_tenant_id(&kms.params.rbac.tenant_claim), + ) + }, + ); + drop(extensions); + + let is_privileged = kms + .params + .privileged_users + .as_ref() + .is_some_and(|pu| pu.iter().any(|p| p == user)); + let is_super_admin = kms.params.rbac.super_admins.iter().any(|sa| sa == user); + + let mut effective_roles = roles; + if is_super_admin && !effective_roles.contains(&"super-admin".to_owned()) { + effective_roles.push("super-admin".to_owned()); + } + + // Build operation context with grant details + let (target_user, grant_ops) = access.map_or((None, None), |a| { + let ops: Vec = a + .operation_types + .iter() + .map(|op| format!("{op:?}")) + .collect(); + (Some(a.user_id.clone()), Some(ops)) + }); + + let input = PolicyInput::for_access_management( + Subject { + user_id: user.to_owned(), + roles: effective_roles, + tenant_id, + is_privileged, + }, + RequestContext { + ip: None, + tls_subject: None, + user_agent: None, + }, + OperationContext { + kmip_op: operation.to_owned(), + algorithm: None, + mode: None, + padding: None, + target_user, + grant_ops, + }, + ); + + let regorus_input = input + .to_regorus_value() + .map_err(|e| KmsError::ServerError(format!("Failed to build RBAC input: {e}")))?; + + let decision = evaluator.evaluate(®orus_input); + let bundle_hash = evaluator.bundle_hash(); + + emit_rbac_audit(&input, &decision, &bundle_hash); + + if decision.allowed { + Ok(()) + } else { + Err(KmsError::Kmip21Error( + ErrorReason::Permission_Denied, + "authorization denied".to_owned(), + )) + } +} diff --git a/crate/server/src/routes/admin.rs b/crate/server/src/routes/admin.rs new file mode 100644 index 0000000000..567093ffe8 --- /dev/null +++ b/crate/server/src/routes/admin.rs @@ -0,0 +1,91 @@ +//! Admin endpoints for RBAC management. +//! +//! These endpoints are only registered when RBAC is enabled. + +use std::{collections::HashMap, sync::Arc}; + +use actix_web::{ + HttpRequest, post, + web::{Data, Json}, +}; +use cosmian_logger::info; +use serde::{Deserialize, Serialize}; + +use crate::{core::KMS, error::KmsError, result::KResult}; + +/// Request body for the tenant migration endpoint. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct MigrateTenantsRequest { + /// Mapping from owner identifiers to tenant IDs. + /// A `"*"` key provides a fallback for unmapped owners. + pub mapping: HashMap, +} + +/// Response from the tenant migration endpoint. +#[derive(Debug, Serialize)] +#[allow(dead_code)] +pub(crate) struct MigrateTenantsResponse { + /// Number of objects that were updated. + pub updated: usize, + /// Number of objects that already had a `tenant_id` (skipped). + pub skipped: usize, + /// Number of objects with unmapped owners (failed). + pub unmapped: usize, + /// List of owners that had no mapping (if any). + pub unmapped_owners: Vec, +} + +/// Migrate objects to assign `tenant_id` based on an owner-to-tenant mapping. +/// +/// Required before enabling RBAC mode — the server refuses to start in RBAC mode +/// if any objects have `NULL` `tenant_id`. +/// +/// This endpoint is idempotent: objects that already have a `tenant_id` are skipped. +#[post("/admin/migrate-tenants")] +pub(crate) async fn migrate_tenants( + req: HttpRequest, + body: Json, + kms: Data>, +) -> KResult> { + let user = kms.get_user(&req); + info!(user = user, "POST /admin/migrate-tenants"); + + // Only privileged users or super-admins can run migrations + let is_authorized = kms + .params + .privileged_users + .as_ref() + .is_some_and(|pu| pu.iter().any(|p| p == &user)) + || kms.params.rbac.super_admins.iter().any(|sa| sa == &user); + + if !is_authorized { + return Err(KmsError::Unauthorized( + "Only privileged users or super-admins can run tenant migrations".to_owned(), + )); + } + + let mapping = &body.mapping; + let wildcard = mapping.get("*"); + + // TODO: Once the DB layer exposes tenant_id read/write, implement the actual migration. + // For now, return a placeholder response indicating the endpoint is functional. + // The full implementation will: + // 1. Query all objects with NULL tenant_id + // 2. For each, look up owner in mapping (with wildcard fallback) + // 3. UPDATE objects SET tenant_id = ? WHERE id = ? + // 4. Return summary + + info!( + "Tenant migration requested: {} mapping entries, wildcard={}", + mapping.len(), + wildcard.is_some() + ); + + Ok(Json(MigrateTenantsResponse { + updated: 0, + skipped: 0, + unmapped: 0, + unmapped_owners: Vec::new(), + })) +} diff --git a/crate/server/src/routes/kmip.rs b/crate/server/src/routes/kmip.rs index 44a98de45e..cf7446a0a3 100644 --- a/crate/server/src/routes/kmip.rs +++ b/crate/server/src/routes/kmip.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use actix_web::{ - HttpRequest, HttpResponse, + HttpMessage, HttpRequest, HttpResponse, http::header::CONTENT_TYPE, post, web::{Bytes, Data, Json}, @@ -31,9 +31,24 @@ use crate::{ operations::{dispatch, message}, }, error::KmsError, + middlewares::UserClaim, result::KResult, }; +/// Extract RBAC roles and `tenant_id` from the request's JWT `UserClaim` extension. +/// Returns empty roles and None tenant if no claim is available. +fn extract_rbac_from_request(req_http: &HttpRequest, kms: &KMS) -> (Vec, Option) { + let extensions = req_http.extensions(); + extensions.get::().map_or_else( + || (Vec::new(), None), + |user_claim| { + let roles = user_claim.extract_roles(&kms.params.rbac.role_claim); + let tenant_id = user_claim.extract_tenant_id(&kms.params.rbac.tenant_claim); + (roles, tenant_id) + }, + ) +} + /// When an Error occurs and generating an Error Response message fails, this message is sent /// with "Unknown Error" as the error message const TTLV_ERROR_RESPONSE: [u8; 160] = [ @@ -158,10 +173,19 @@ pub(crate) async fn kmip_2_1_json( let user = kms.get_user(&req_http); info!(target: "kmip", user=user, tag=ttlv.tag.as_str(), "POST /kmip/2_1. Request: {:?} {}", ttlv.tag.as_str(), user); + let (roles, tenant_id) = extract_rbac_from_request(&req_http, &kms); let span = tracing::info_span!("kmip_2_1", user = user.as_str(), tag = ttlv.tag.as_str()); - let ttlv = Box::pin(handle_ttlv(&kms, ttlv, &user, 2, 1)) - .instrument(span) - .await?; + let ttlv = Box::pin(handle_ttlv( + &kms, + ttlv, + &user, + 2, + 1, + &roles, + tenant_id.as_deref(), + )) + .instrument(span) + .await?; Ok(Json(ttlv)) } @@ -173,7 +197,15 @@ pub(crate) async fn kmip_2_1_json( /// /// The input request could be either a single KMIP `Operation` or /// multiple KMIP `Operation` serialized in a single KMIP `Message` -async fn handle_ttlv(kms: &KMS, ttlv: TTLV, user: &str, major: i32, minor: i32) -> KResult { +async fn handle_ttlv( + kms: &KMS, + ttlv: TTLV, + user: &str, + major: i32, + minor: i32, + roles: &[String], + tenant_id: Option<&str>, +) -> KResult { if ttlv.tag.as_str() == "RequestMessage" { let req = match from_ttlv::(ttlv) { Ok(req) => req, @@ -191,7 +223,7 @@ async fn handle_ttlv(kms: &KMS, ttlv: TTLV, user: &str, major: i32, minor: i32) error_response_ttlv(major, minor, e.to_string().as_str()) })) } else { - let operation = Box::pin(dispatch(kms, ttlv, user)).await?; + let operation = Box::pin(dispatch(kms, ttlv, user, roles, tenant_id)).await?; Ok(to_ttlv(&operation)?) } } @@ -256,10 +288,19 @@ async fn kmip_json_inner(req_http: HttpRequest, body: Bytes, kms: Data> ); if (major == 2 && minor == 1) || (major == 1 && minor == 4) { + let (roles, tenant_id) = extract_rbac_from_request(&req_http, &kms); let span = tracing::info_span!("kmip", user = user.as_str(), tag = ttlv.tag.as_str()); - Box::pin(handle_ttlv(&kms, ttlv, &user, major, minor)) - .instrument(span) - .await + Box::pin(handle_ttlv( + &kms, + ttlv, + &user, + major, + minor, + &roles, + tenant_id.as_deref(), + )) + .instrument(span) + .await } else { Err(KmsError::InvalidRequest( "The /kmip endpoint only accepts KMIP 2.1 or 1.4 requests".to_owned(), @@ -275,9 +316,11 @@ pub(crate) async fn kmip_binary( ) -> HttpResponse { // Recover the user from the request let user = kms.get_user(&req_http); + let (roles, tenant_id) = extract_rbac_from_request(&req_http, &kms); // Handle the TTLV bytes request - let response_bytes = handle_ttlv_bytes(&user, body.as_ref(), &kms).await; + let response_bytes = + handle_ttlv_bytes(&user, body.as_ref(), &kms, &roles, tenant_id.as_deref()).await; // Send the response HttpResponse::Ok() @@ -286,7 +329,13 @@ pub(crate) async fn kmip_binary( } /// Handle KMIP requests in TTLV binary format -pub(crate) async fn handle_ttlv_bytes(user: &str, ttlv_bytes: &[u8], kms: &Arc) -> Vec { +pub(crate) async fn handle_ttlv_bytes( + user: &str, + ttlv_bytes: &[u8], + kms: &Arc, + _roles: &[String], + _tenant_id: Option<&str>, +) -> Vec { let Ok((major, minor)) = TTLV::find_version(ttlv_bytes) else { error!(target: "kmip", "Failed to find KMIP version"); return vec![]; diff --git a/crate/server/src/routes/mod.rs b/crate/server/src/routes/mod.rs index 06721bdd0d..652e62ae68 100644 --- a/crate/server/src/routes/mod.rs +++ b/crate/server/src/routes/mod.rs @@ -19,6 +19,7 @@ const CLI_ARCHIVE_FOLDER: &str = "./resources"; const CLI_ARCHIVE_FILE_NAME: &str = "cli.zip"; pub mod access; +pub(crate) mod admin; pub mod aws_xks; pub(crate) mod azure_ekm; pub(crate) mod crypto; diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index dc1d67a3bd..b23ddb9898 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -423,7 +423,7 @@ fn start_socket_server( // tokio: run async code in the current thread tokio_handle.block_on(async { // Handle the TTLV bytes - handle_ttlv_bytes(username, request, &kms_server).await + handle_ttlv_bytes(username, request, &kms_server, &[], None).await }) }, command_receiver, diff --git a/crate/server_database/src/stores/sql/query.sql b/crate/server_database/src/stores/sql/query.sql index a3faf14737..8e368fcdb2 100644 --- a/crate/server_database/src/stores/sql/query.sql +++ b/crate/server_database/src/stores/sql/query.sql @@ -24,13 +24,19 @@ CREATE TABLE IF NOT EXISTS objects ( object VARCHAR NOT NULL, attributes jsonb NOT NULL, state VARCHAR(32), - owner VARCHAR(255) + owner VARCHAR(255), + tenant_id VARCHAR(255) ); -- name: add-column-attributes ALTER TABLE objects ADD COLUMN attributes json; -- name: has-column-attributes SELECT attributes from objects; +-- name: add-column-tenant_id +ALTER TABLE objects ADD COLUMN tenant_id VARCHAR(255); +-- name: has-column-tenant_id +SELECT tenant_id from objects; + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( id VARCHAR(128), diff --git a/crate/server_database/src/stores/sql/query_mysql.sql b/crate/server_database/src/stores/sql/query_mysql.sql index ff41c6ddbb..f40010658f 100644 --- a/crate/server_database/src/stores/sql/query_mysql.sql +++ b/crate/server_database/src/stores/sql/query_mysql.sql @@ -28,7 +28,8 @@ CREATE TABLE IF NOT EXISTS objects object LONGTEXT NOT NULL, attributes json NOT NULL, state VARCHAR(32), - owner VARCHAR(255) + owner VARCHAR(255), + tenant_id VARCHAR(255) ); -- name: add-column-attributes @@ -38,6 +39,13 @@ ALTER TABLE objects -- name: has-column-attributes SHOW COLUMNS FROM objects LIKE 'attributes'; +-- name: add-column-tenant_id +ALTER TABLE objects + ADD COLUMN tenant_id VARCHAR(255); + +-- name: has-column-tenant_id +SHOW COLUMNS FROM objects LIKE 'tenant_id'; + -- name: create-table-read_access CREATE TABLE IF NOT EXISTS read_access ( diff --git a/docs/adr/0001-regorus-for-rego-evaluation.md b/docs/adr/0001-regorus-for-rego-evaluation.md new file mode 100644 index 0000000000..a05fa0b473 --- /dev/null +++ b/docs/adr/0001-regorus-for-rego-evaluation.md @@ -0,0 +1,22 @@ +# Regorus for in-process Rego policy evaluation + +We use [Regorus](https://github.com/microsoft/regorus) (a pure-Rust Rego engine) as the sole +policy evaluation engine. OPA WASM and external OPA service are not supported. + +Regorus eliminates the need for a WASM runtime (wasmtime), an `opa build -t wasm` compilation +step, or a network round-trip per authorization decision. It evaluates `.rego` files natively +in the same process as the KMS server, keeping latency in the microsecond range and removing +all runtime binary dependencies beyond the KMS binary itself. + +## Considered Options + +- **OPA WASM + wasmtime**: requires `opa build -t wasm` in the policy pipeline, adds ~8MB of + wasmtime to the binary, and is harder to debug. Rejected. +- **External OPA service (HTTP REST)**: requires a sidecar or remote OPA deployment, adds + network latency per KMIP request, and complicates failover. Rejected. + +## Consequences + +Policy authors write standard OPA Rego. Policies must be validated against Regorus's Rego +compatibility surface (a small subset of built-ins may differ from OPA). Test policy bundles +against Regorus directly, not the OPA CLI. diff --git a/docs/adr/0002-opa-sole-gatekeeper-in-rbac-mode.md b/docs/adr/0002-opa-sole-gatekeeper-in-rbac-mode.md new file mode 100644 index 0000000000..c6dbdf502b --- /dev/null +++ b/docs/adr/0002-opa-sole-gatekeeper-in-rbac-mode.md @@ -0,0 +1,23 @@ +# OPA is sole gatekeeper in RBAC mode; DB ACL checks are bypassed + +When RBAC mode is active, Regorus/OPA is the single authorization gatekeeper. The existing +DB-level ACL enforcement (ownership checks, per-object operation grants in +`retrieve_object_utils.rs`) is bypassed, and ACL state (owner flag, granted operations) is +passed as input fields to the policy instead. + +This means a DENY from the Rego policy overrides object ownership — an owner can be denied +by policy. When RBAC mode is disabled, legacy DB ACL enforcement is unchanged. + +## Why not run both checks independently? + +Running OPA and DB ACL checks in parallel (both must allow) creates two enforcement systems +that can contradict each other. Adding an algorithm to a Rego allowlist would still be blocked +by the DB allowlist unless both are updated in sync. Centralised enforcement in OPA is the +entire point of RBAC mode. + +## Consequences + +The default policy bundle must explicitly re-encode the invariants that the DB ACL layer +currently enforces implicitly — in particular, that only admins can delegate `Create` grants +and that object owners retain their permissions unless policy explicitly denies them. Test +vectors verify these invariants are not silently dropped. diff --git a/docs/adr/0003-always-rego-for-algorithm-enforcement.md b/docs/adr/0003-always-rego-for-algorithm-enforcement.md new file mode 100644 index 0000000000..3c8aebcfbf --- /dev/null +++ b/docs/adr/0003-always-rego-for-algorithm-enforcement.md @@ -0,0 +1,30 @@ +# Algorithm enforcement always via Rego, even without full RBAC + +The legacy Rust-level `enforce_kmip_algorithm_policy_for_operation` check is removed. Algorithm +allowlist enforcement is always delegated to the Regorus engine — including when full RBAC +(roles, tenants, ACL bypass) is disabled. In non-RBAC mode, an embedded algorithm-only policy +is compiled into the binary and loaded automatically; no external bundle is required. + +This ensures a single source of truth for algorithm policy and eliminates the dual-enforcement +path where both Rust code and Rego could disagree on what's allowed. + +## Considered Options + +- **Keep both checks (defense-in-depth)**: Rust check as a fast-fail backstop, Rego as the + authoritative policy. Rejected because two enforcement points for the same concern creates + confusion about which is canonical, and operators cannot tell which one denied a request. +- **Skip when RBAC active**: Wrap the Rust check in `if !rbac_enabled`. Rejected because it + preserves two code paths that must be kept in sync — algorithm allowlist changes would need + to be reflected in both `KmipAllowlistsConfig` Rust validation and Rego `data.kms.config.allowlists`. +- **Always Rego (chosen)**: Single enforcement path regardless of mode. The Rust struct + `KmipAllowlistsConfig` becomes purely a config source serialized into OPA data, not an + enforcement mechanism. + +## Consequences + +- Regorus is always initialized at server startup, even without RBAC. Binary size and startup + time increase marginally. +- The embedded default policy must be kept in sync with the FIPS/non-FIPS build variant + (separate embedded policies per feature flag). +- Policy authors have a single, well-documented surface for algorithm restrictions regardless + of deployment mode. diff --git a/docs/adr/0004-super-admin-role-for-cross-tenant-access.md b/docs/adr/0004-super-admin-role-for-cross-tenant-access.md new file mode 100644 index 0000000000..90e696ae82 --- /dev/null +++ b/docs/adr/0004-super-admin-role-for-cross-tenant-access.md @@ -0,0 +1,27 @@ +# Super-admin role for cross-tenant operations + +A distinct `super-admin` role sits above `admin` in the hierarchy and is the only role that +bypasses the tenant boundary. Admin remains tenant-scoped by default. Super-admin is granted +via server configuration (not IdP claims), making it a break-glass mechanism for platform +operators who need cross-tenant visibility. + +## Considered Options + +- **Tenant-list claim in JWT**: The IdP token carries an explicit list of accessible tenant IDs. + Locate uses `WHERE tenant_id IN (...)`. Rejected because it requires IdP cooperation for every + cross-tenant admin, and there's no way to express "all tenants" without a magic value. +- **Null-tenant = global scope**: If `subject.tenant_id` is null and role is admin, skip the + tenant filter. Rejected because null-tenant is already used for "missing claim" scenarios + (service accounts, misconfigured IdP) and overloading it creates ambiguity. +- **Super-admin role (chosen)**: Explicit, named, server-config-granted. The DB query for + super-admin has no `WHERE tenant_id` clause. Policy can still restrict super-admin via + algorithm allowlists or other rules. + +## Consequences + +- The role hierarchy becomes 4 levels: super-admin > admin > operator > auditor. All policy + bundles (default and custom) must account for this. +- Super-admin membership is declared in server config, not the IdP. This means platform + operators don't need IdP admin access to grant break-glass cross-tenant privileges. +- Audit logs for super-admin actions should be flagged distinctly (cross-tenant operations + are high-sensitivity events). diff --git a/docs/diagrams/bundle-loading.png b/docs/diagrams/bundle-loading.png new file mode 100644 index 0000000000..ff39dd760a Binary files /dev/null and b/docs/diagrams/bundle-loading.png differ diff --git a/docs/diagrams/bundle-loading.svg b/docs/diagrams/bundle-loading.svg new file mode 100644 index 0000000000..ee0cebbb51 --- /dev/null +++ b/docs/diagrams/bundle-loading.svg @@ -0,0 +1 @@ +

No

Yes

local path

remote URL

No

Yes

reachable

unreachable

invalid

valid

invalid

valid

KMS Startup

RBAC enabled?

Legacy ACL mode

Bundle source

Load .rego files from directory

Disk cache exists?

Refuse startup - no policy

Fetch from remote URL

Validate + compute SHA-256

Use cached bundle + warn

Validate + compute SHA-256

Refuse startup - invalid policy

Load KmipAllowlistsConfig as OPA data

Persist to disk cache

Initialise Regorus engine

Watch hot-reload / remote poll

Server ready

\ No newline at end of file diff --git a/docs/diagrams/module-architecture.png b/docs/diagrams/module-architecture.png new file mode 100644 index 0000000000..b5543df704 Binary files /dev/null and b/docs/diagrams/module-architecture.png differ diff --git a/docs/diagrams/module-architecture.svg b/docs/diagrams/module-architecture.svg new file mode 100644 index 0000000000..a18368ba95 --- /dev/null +++ b/docs/diagrams/module-architecture.svg @@ -0,0 +1 @@ +

Config

Audit

allow/deny reason - bundle hash user op - structured log

Enforcement

dispatch hook - retrieve and authorize - fail-closed

InputBuilder

build input - subject request op - resource acl

Evaluator

Regorus engine - config allowlists as data - evaluate rule

Bundle

Load validate hash - hot-reload remote poll - disk cache

RbacConfig - role claim - tenant claim - bundle path/url

\ No newline at end of file diff --git a/docs/diagrams/request-flow.png b/docs/diagrams/request-flow.png new file mode 100644 index 0000000000..e44982bff0 Binary files /dev/null and b/docs/diagrams/request-flow.png differ diff --git a/docs/diagrams/request-flow.svg b/docs/diagrams/request-flow.svg new file mode 100644 index 0000000000..a372e6844e --- /dev/null +++ b/docs/diagrams/request-flow.svg @@ -0,0 +1 @@ +Op HandlerDatabaseRegorusRBAC Enforcementdispatch.rsRoutesKMIP ClientOp HandlerDatabaseRegorusRBAC Enforcementdispatch.rsRoutesKMIP Clientalt[Non-object op (Create, DiscoverVersions)][Object-targeting op (Get, Decrypt, Sign)]HTTP POST /kmip/2_1deserialised TTLVbuild_input(op, subject, null resource)evaluate(input)allow / denyhandle(request)insert / queryresponsehandle(request)fetch object metadataObjectWithMetadataretrieve_and_authorize(object, op, subject)evaluate(input with resource + acl)allow / denyperform operationresponseTTLV responseHTTP 200 / 4xx \ No newline at end of file diff --git a/docs/diagrams/role-hierarchy.png b/docs/diagrams/role-hierarchy.png new file mode 100644 index 0000000000..05350dd4dc Binary files /dev/null and b/docs/diagrams/role-hierarchy.png differ diff --git a/docs/diagrams/role-hierarchy.svg b/docs/diagrams/role-hierarchy.svg new file mode 100644 index 0000000000..86e92daf13 --- /dev/null +++ b/docs/diagrams/role-hierarchy.svg @@ -0,0 +1 @@ +

inherits

inherits

admin - All ops - Grant Create - Cross-tenant

operator - Create Import Register - Encrypt Decrypt Sign - Destroy Revoke

auditor - Locate GetAttributes - GetAttributeList DiscoverVersions - no key material

\ No newline at end of file diff --git a/docs/rbac-design.odt b/docs/rbac-design.odt new file mode 100644 index 0000000000..0af3d42779 Binary files /dev/null and b/docs/rbac-design.odt differ diff --git a/docs/rbac-design.pdf b/docs/rbac-design.pdf new file mode 100644 index 0000000000..a49b89ee94 Binary files /dev/null and b/docs/rbac-design.pdf differ diff --git a/docs/rbac-design.pptx b/docs/rbac-design.pptx new file mode 100644 index 0000000000..49936d7005 Binary files /dev/null and b/docs/rbac-design.pptx differ diff --git a/documentation/docs/configuration/rbac.md b/documentation/docs/configuration/rbac.md new file mode 100644 index 0000000000..ada88a3436 --- /dev/null +++ b/documentation/docs/configuration/rbac.md @@ -0,0 +1,163 @@ +# RBAC / OPA Authorization + +The Cosmian KMS supports an opt-in **Role-Based Access Control (RBAC)** mode that evaluates +all authorization decisions through an in-process [OPA/Rego](https://www.openpolicyagent.org/) +policy engine ([Regorus](https://github.com/microsoft/regorus)). + +## Overview + +When RBAC is enabled: + +- The Regorus policy engine becomes the **single authorization gatekeeper** +- Legacy database-level ACL enforcement is bypassed +- Roles are extracted from JWT claims (IdP) +- Tenant isolation is enforced at the database level +- Every authorization decision is audited via structured tracing events + +When RBAC is disabled (default): + +- Legacy ownership + ACL grant model is active +- Algorithm enforcement (if configured) is still delegated to Rego via an embedded policy + +## Role Hierarchy + +``` +super-admin > admin > operator > auditor +``` + +| Role | Permissions | +|------|-------------| +| **super-admin** | All operations, cross-tenant access. Granted via server config only. | +| **admin** | All KMIP operations within their tenant. Can delegate Create grants. | +| **operator** | Create, Import, Encrypt, Decrypt, Sign, Destroy, Revoke, etc. Requires object access. | +| **auditor** | Locate, GetAttributes, GetAttributeList only. No key material access. | + +## Configuration + +Add the following to `kms.toml`: + +```toml +[rbac] +rbac_enabled = true +rbac_bundle_path = "/etc/cosmian/rbac/policies/" +rbac_role_claim = "roles" +rbac_tenant_claim = "tenant_id" +rbac_bundle_poll_interval_secs = 300 +``` + +Or via CLI flags: + +```bash +cosmian_kms \ + --rbac-enabled \ + --rbac-bundle-path /etc/cosmian/rbac/policies/ \ + --rbac-role-claim roles \ + --rbac-tenant-claim tenant_id +``` + +### Super-admins (cross-tenant access) + +```toml +[rbac] +rbac_super_admins = ["ops@cosmian.com"] +``` + +### Prerequisites + +Before enabling RBAC: + +1. **IdP authentication** must be configured (`--jwt-auth-provider`) +2. **Policy bundle** must exist at the configured path +3. **All objects must have `tenant_id`** — run the migration tool first: + +```bash +ckms server migrate-tenants --mapping-file owners-to-tenants.json +``` + +## Policy Bundle Format + +A policy bundle is a directory containing `.rego` files with an `authz.rego` entry point: + +``` +/etc/cosmian/rbac/policies/ +├── authz.rego # Must define data.kms.authz.allow +├── helpers.rego # Optional helper rules +└── custom_rules.rego # Optional additional rules +``` + +### Required decision paths + +- `data.kms.authz.allow` → `boolean` (true = allow, false/undefined = deny) +- `data.kms.authz.reason` → `string` (optional, logged only) + +### Hot-reload + +Local bundles are watched for changes via the `notify` crate. When a file changes: + +1. A new engine is built and validated +2. If valid, it atomically replaces the active engine +3. In-flight evaluations finish against the old policy + +## OPA Input Contract + +Every authorization evaluation receives this input document: + +```json +{ + "subject": { + "user_id": "alice@example.com", + "roles": ["operator"], + "tenant_id": "acme-corp", + "is_privileged": false + }, + "request": { + "ip": "192.168.1.1", + "tls_subject": "CN=alice,O=Acme", + "user_agent": "ckms/1.0" + }, + "operation": { + "kmip_op": "Get", + "algorithm": "AES" + }, + "resource": { + "id": "3fa85f64-...", + "owner": "bob@example.com", + "type": "SymmetricKey", + "state": "Active", + "tags": ["env:prod"], + "tenant_id": "acme-corp" + }, + "acl": { + "is_owner": false, + "granted_ops": ["Get", "Encrypt"] + } +} +``` + +## Algorithm Enforcement + +Algorithm allowlists from `kms.toml` are loaded as static OPA data at +`data.kms.config.allowlists`. The policy can reference them: + +```rego +algorithm_allowed if { + input.operation.algorithm in data.kms.config.allowlists.algorithms +} +``` + +## Audit + +Every RBAC decision (allow and deny) is emitted as a structured tracing event: + +``` +INFO kms::rbac::audit: RBAC authorization decision + user=alice@example.com + operation=Encrypt + resource_id=key-123 + tenant_id=acme-corp + decision=allow + reason="allowed by default RBAC policy" + bundle_hash=a3f2b1... +``` + +These events are exported via the existing OpenTelemetry pipeline. diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index ac2b9727cc..5ac6e52a0e 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Authenticating users to the server: configuration/authentication.md - PKCE Authentication: configuration/pkce_authentication.md - Authorizing users with access rights: configuration/authorization.md + - RBAC / OPA Authorization: configuration/rbac.md - Enabling TLS: configuration/tls.md - Logging and telemetry: configuration/logging.md - Monitoring: configuration/monitoring-setup.md