This document describes the complete security data-path in Cyber Ware: how requests are authenticated (AuthN), how authorization decisions are enforced (AuthZ / PEP), how AccessScope is produced, and how the Secure ORM layer (SecureConn) applies it to every database query.
For the full architectural design (AuthZEN model, predicate types, caching, deployment modes), see docs/arch/authorization/DESIGN.md.
- Rule: Authentication is handled by API Gateway middleware — modules receive a ready-to-use
SecurityContext. - Rule: Use
SecureConnfor all DB access in handlers/services. Modules cannot access raw database connections/pools. - Rule: Derive
Scopableon SeaORM entities with tenant/resource columns. - Rule: Use
PolicyEnforcerfromauthz-resolver-sdkfor all authorization decisions. Do not constructAccessScopemanually in production code. - Rule: Every sensitive DB access MUST be covered by a PDP decision (via
PolicyEnforcer). Exception: the approved prefetch-first flow for GET/UPDATE/DELETE may read withAccessScope::allow_all()before the PDP call, provided the required compensating checks are applied (see GET prefetch pattern and UPDATE/DELETE prefetch + TOCTOU safety). - Rule: Fail-closed — denied PDP decisions, unreachable PDP, and missing constraints all result in 403 Forbidden.
- Rule: Map
EnforcerErrorto domain errors. Never expose PDP internals to the client. - Rule: Use
.authenticated()onOperationBuilderfor protected endpoints,.public()for unauthenticated ones. - Rule: No plain SQL in handlers/services/repos. Raw SQL is allowed only in migration infrastructure. See
11_database_patterns.mdfor migration rules.
Request → API Gateway (AuthN middleware) → SecurityContext
↓
Module Handler (PEP)
↓
PolicyEnforcer → AuthZ Resolver (PDP) → decision + constraints
↓
PEP Compiler → AccessScope
↓
SecureConn (SQL WHERE) → Database
Three components work together:
- AuthN Resolver — validates bearer tokens, produces
SecurityContext(subject identity, tenant, token scopes). Uses the gateway + plugin pattern to delegate to vendor-specific IdPs. - AuthZ Resolver (PDP) — evaluates policies, returns
decision + constraints. Uses the gateway + plugin pattern to delegate to vendor-specific authorization services. - Domain modules (PEP) — call PDP via
PolicyEnforcer, compile constraints toAccessScope, pass toSecureConnfor SQL-level enforcement.
Module developers interact primarily with the PEP layer — the AuthN and AuthZ resolvers are infrastructure modules.
When registering REST endpoints, declare whether each route requires authentication using the type-state builder pattern:
// Protected endpoint — requires valid bearer token + license check
OperationBuilder::get("/users-info/v1/users")
.authenticated()
.require_license_features::<License>([])
.handler(handlers::list_users)
// ...
// Public endpoint — no token required
OperationBuilder::get("/users-info/v1/health")
.public()
.handler(handlers::health)
// ...The builder enforces at compile time that every route declares its auth posture before .register():
.authenticated()marks the route as protected, then requires.require_license_features::<L>(features)(or.no_license_required()) before registration..public()marks the route as unauthenticated and automatically satisfies the license requirement.
The API Gateway middleware uses these declarations to decide how to handle each request:
- Protected routes: extract bearer token → call AuthN Resolver → inject
SecurityContextinto request extensions. Returns 401 if token is invalid. - Public routes: inject
SecurityContext::anonymous()(zero-UUID subject and tenant, empty scopes). - Unregistered routes: behavior depends on
require_auth_by_defaultconfig flag.
The API Gateway injects SecurityContext as an Axum Extension. Extract it with the standard Extension extractor:
use modkit::api::prelude::*;
use modkit_security::SecurityContext;
pub async fn list_users(
Extension(ctx): Extension<SecurityContext>,
Extension(svc): Extension<Arc<Service>>,
OData(query): OData,
) -> ApiResult<JsonPage<serde_json::Value>> {
let page = svc.users.list_users_page(&ctx, &query).await?;
Ok(Json(page))
}| Field | Required | Description |
|---|---|---|
subject_id |
Yes | Unique subject identifier (from token sub claim) |
subject_tenant_id |
Yes | Tenant the subject belongs to |
subject_type |
No | GTS type identifier (e.g., gts.cf.core.security.subject_user.v1~) |
token_scopes |
Yes | Capability restrictions (["*"] for first-party apps) |
bearer_token |
No | Original token (wrapped in Secret<String>, forwarded to PDP) |
- Declare dependency on
authz-resolverin your module:
#[modkit::module(
name = "my_module",
deps = ["authz-resolver"],
capabilities = [db, rest],
)]
pub struct MyModule { /* ... */ }- Resolve the AuthZ client from
ClientHubduringinit():
use authz_resolver_sdk::AuthZResolverClient;
async fn init(&self, ctx: &ModuleCtx) -> anyhow::Result<()> {
let authz = ctx.client_hub().get::<dyn AuthZResolverClient>()?;
// Pass authz to your domain service...
}- Create a single
PolicyEnforcerin your service and clone it into sub-services:
use authz_resolver_sdk::PolicyEnforcer;
let enforcer = PolicyEnforcer::new(authz);
// Clone enforcer into each sub-service (cheap Arc clone)Each resource type declares which properties the PEP can compile from PDP constraints into SQL:
use authz_resolver_sdk::pep::ResourceType;
use modkit_security::pep_properties;
pub const USER: ResourceType = ResourceType {
name: "my_module.user",
supported_properties: &[
pep_properties::OWNER_TENANT_ID, // tenant scoping
pep_properties::RESOURCE_ID, // resource-level access
],
};
pub const DOCUMENT: ResourceType = ResourceType {
name: "my_module.document",
supported_properties: &[
pep_properties::OWNER_TENANT_ID,
pep_properties::RESOURCE_ID,
pep_properties::OWNER_ID, // ownership-based access
"category_id", // custom domain property
],
};Well-known properties from modkit_security::pep_properties:
OWNER_TENANT_ID— tenant that owns the resourceRESOURCE_ID— the resource's primary keyOWNER_ID— the user who owns the resource
Define action constants used in PDP requests:
pub const GET: &str = "get";
pub const LIST: &str = "list";
pub const CREATE: &str = "create";
pub const UPDATE: &str = "update";
pub const DELETE: &str = "delete";Two entry points:
// Simple: always requires constraints from PDP
let scope = enforcer
.access_scope(&ctx, &resource_type, action, resource_id)
.await?;
// Advanced: per-request overrides via AccessRequest
let scope = enforcer
.access_scope_with(&ctx, &resource_type, action, resource_id, &access_request)
.await?;Both return Result<AccessScope, EnforcerError>. The AccessScope is then passed to SecureConn methods for SQL-level enforcement.
Use AccessRequest for per-call overrides:
use authz_resolver_sdk::pep::AccessRequest;
use modkit_security::pep_properties;
let request = AccessRequest::new()
// Pass resource properties to PDP for narrow constraints
.resource_property(pep_properties::OWNER_TENANT_ID, tenant_id)
// Override constraint requirement (default: true)
.require_constraints(false)
// Override tenant context
.context_tenant_id(specific_tenant_id);The bridge between PDP authorization constraints and SQL WHERE clauses is property resolution — mapping abstract PEP property names (like "owner_tenant_id") to concrete database columns (like Column::TenantId).
Every entity that participates in row-level security derives Scopable with a #[secure(...)] attribute declaring its security dimensions:
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Scopable)]
#[sea_orm(table_name = "users")]
#[secure(
tenant_col = "tenant_id",
resource_col = "id",
no_owner,
no_type
)]
pub struct Model { /* ... */ }Available dimension attributes:
| Attribute | PEP property auto-mapped | Description |
|---|---|---|
tenant_col = "col" |
owner_tenant_id → Column::Col |
Tenant ownership column |
resource_col = "col" |
id → Column::Col |
Resource identity (usually PK) |
owner_col = "col" |
owner_id → Column::Col |
User ownership column |
type_col = "col" / no_type |
(no auto-map) | GTS type column |
no_tenant / no_resource / no_owner |
(dimension absent) | Opt out of a dimension |
unrestricted |
(no scoping at all) | Global tables without scoping columns |
Rule: All four dimensions must be declared (either *_col or no_*), unless unrestricted is used.
The Scopable derive macro automatically maps dimension columns to well-known PEP property names via resolve_property():
| Dimension attribute | PEP property name | Constant |
|---|---|---|
tenant_col = "tenant_id" |
"owner_tenant_id" |
pep_properties::OWNER_TENANT_ID |
resource_col = "id" |
"id" |
pep_properties::RESOURCE_ID |
owner_col = "user_id" |
"owner_id" |
pep_properties::OWNER_ID |
This means if you declare tenant_col = "tenant_id", the macro generates:
fn resolve_property(property: &str) -> Option<Self::Column> {
match property {
"owner_tenant_id" => Some(Column::TenantId),
"id" => Some(Column::Id),
// ...
_ => None,
}
}When the PDP returns a constraint like In("owner_tenant_id", [uuid1, uuid2]), SecureConn calls resolve_property("owner_tenant_id"), gets Column::TenantId, and generates WHERE tenant_id IN (uuid1, uuid2).
For domain-specific properties beyond the three standard ones, use pep_prop(property = "column"):
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Scopable)]
#[sea_orm(table_name = "addresses")]
#[secure(
tenant_col = "tenant_id",
resource_col = "id",
owner_col = "user_id",
no_type,
pep_prop(city_id = "city_id")
)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub city_id: Uuid,
pub street: String,
// ...
}This adds "city_id" => Some(Column::CityId) to the generated resolve_property() match.
To use the custom property, include it in both ResourceType.supported_properties and the PDP policy:
pub const ADDRESS: ResourceType = ResourceType {
name: "my_module.address",
supported_properties: &[
pep_properties::OWNER_TENANT_ID,
pep_properties::RESOURCE_ID,
pep_properties::OWNER_ID,
"city_id", // matches the pep_prop key
],
};When the PDP returns constraints referencing "city_id", the PEP compiler includes them in the AccessScope, and SecureConn resolves them to Column::CityId for the SQL WHERE clause.
ResourceType.supported_properties ← declares what PEP can compile
↓
PDP constraint: In("city_id", [uuid]) ← PDP returns constraint on this property
↓
PEP compiler: ScopeFilter::In("city_id", [uuid]) → AccessScope
↓
SecureConn → build_scope_condition::<E>() → E::resolve_property("city_id")
↓
Column::CityId → WHERE city_id IN (uuid)
If resolve_property() returns None for a property (unknown property), the entire constraint fails (fail-closed → deny-all).
The macro enforces at compile time:
- Reserved names rejected —
owner_tenant_id,id,owner_idcannot be used inpep_prop()(usetenant_col,resource_col,owner_colinstead). - No duplicates — each property name may appear only once.
- Empty names rejected — property and column names must be non-empty.
- Forbidden with
unrestricted—pep_prop()cannot be combined withunrestricted.
Use #[secure(unrestricted)] only for truly global tables where the entity has no scoping columns. Notes:
secure_insertdoes not requiretenant_idfor such entities.- Queries with a scope that contains tenant IDs will be denied (by policy: tenants requested but entity has no
tenant_col). - If you need to read/write a global table within a tenant-scoped request, do not use
unrestricted. Model it with explicit columns and use an appropriate scope shape (oftenresources_only).
All patterns below are from the canonical users-info example module.
The simplest case: no resource ID, constraints required by default.
pub async fn list_users_page(
&self,
ctx: &SecurityContext,
query: &ODataQuery,
) -> Result<Page<User>, DomainError> {
let conn = self.db.conn()?;
// PDP returns constraints → compiled to AccessScope
let scope = self.policy_enforcer
.access_scope(ctx, &resources::USER, actions::LIST, None)
.await?;
// SecureConn applies scope as SQL WHERE clause
let page = self.repo.list_page(&conn, &scope, query).await?;
Ok(page)
}For point reads, a two-step pattern provides narrower PDP constraints:
pub async fn get_user(
&self,
ctx: &SecurityContext,
id: Uuid,
) -> Result<User, DomainError> {
let conn = self.db.conn()?;
// Step 1: Prefetch with allow_all to extract owner_tenant_id
let prefetch_scope = AccessScope::allow_all();
let user = self.repo
.get(&conn, &prefetch_scope, id)
.await?
.ok_or_else(|| DomainError::user_not_found(id))?;
// Step 2: PDP call with prefetched properties → narrow constraint
let scope = self.policy_enforcer
.access_scope_with(
ctx,
&resources::USER,
actions::GET,
Some(id),
&AccessRequest::new()
.resource_property(pep_properties::OWNER_TENANT_ID, user.tenant_id)
.require_constraints(false),
)
.await?;
// Step 3: If unconstrained, return prefetch; otherwise scoped re-read
let user = if scope.is_unconstrained() {
user
} else {
self.repo
.get(&conn, &scope, id)
.await?
.ok_or_else(|| DomainError::user_not_found(id))?
};
Ok(user)
}Why prefetch? Without it, PDP would need to expand the full tenant subtree. By providing owner_tenant_id, PDP can return a simple eq predicate. If PDP returns decision: true without constraints (unconstrained), we skip the second DB query entirely.
CREATE operations pass the target owner_tenant_id as a resource property so the PDP can validate the subject is allowed to create in that tenant:
pub async fn create_user(
&self,
ctx: &SecurityContext,
new_user: NewUser,
) -> Result<User, DomainError> {
let conn = self.db.conn()?;
// PDP validates: can subject create in this tenant?
let scope = self.policy_enforcer
.access_scope_with(
ctx,
&resources::USER,
actions::CREATE,
None,
&AccessRequest::new()
.resource_property(pep_properties::OWNER_TENANT_ID, new_user.tenant_id),
)
.await?;
// SecureConn INSERT validates tenant_id is within the scope
let created = self.repo.create(&conn, &scope, user).await?;
Ok(created)
}Mutations combine prefetch (for narrow PDP constraints) with scoped writes (for TOCTOU protection):
pub async fn update_user(
&self,
ctx: &SecurityContext,
id: Uuid,
patch: UserPatch,
) -> Result<User, DomainError> {
let conn = self.db.conn()?;
// Step 1: Prefetch to extract owner_tenant_id
let prefetch_scope = AccessScope::allow_all();
let mut current = self.repo
.get(&conn, &prefetch_scope, id)
.await?
.ok_or_else(|| DomainError::user_not_found(id))?;
// Step 2: PDP call with prefetched properties
let scope = self.policy_enforcer
.access_scope_with(
ctx,
&resources::USER,
actions::UPDATE,
Some(id),
&AccessRequest::new()
.resource_property(pep_properties::OWNER_TENANT_ID, current.tenant_id),
)
.await?;
// Step 3: Apply patch
if let Some(email) = patch.email { current.email = email; }
if let Some(name) = patch.display_name { current.display_name = name; }
// Step 4: Scoped write — WHERE clause enforces TOCTOU safety
let updated = self.repo.update(&conn, &scope, current).await?;
Ok(updated)
}TOCTOU protection: between prefetch and mutation, the resource's tenant might change (race condition). The scoped write includes WHERE (scope constraints), so if the tenant changed, the update returns 0 rows → treated as not found.
use modkit_db::secure::AccessScope;
pub async fn list_users(
Extension(ctx): Extension<SecurityContext>,
Extension(db): Extension<Arc<DbHandle>>,
) -> ApiResult<JsonPage<UserDto>> {
let secure_conn = db.sea_secure();
// In production, scope comes from PolicyEnforcer (see CRUD patterns above)
let scope = enforcer.access_scope(&ctx, &resources::USER, actions::LIST, None).await?;
let users = secure_conn
.find::<user::Entity>(&scope)
.all(&secure_conn)
.await?;
Ok(Json(users.into_iter().map(UserDto::from).collect()))
}| Scope | Entity has column? | Result |
|---|---|---|
Empty (deny_all) |
N/A | deny all (WHERE false) |
Unconstrained (allow_all) |
N/A | no filtering (WHERE true) |
| Tenants only | has tenant_col |
tenant_col IN (tenant_ids) |
| Tenants only | no tenant_col |
deny all |
| Resources only | has resource_col |
resource_col IN (resource_ids) |
| Resources only | no resource_col |
deny all |
| Tenants + resources | has both | AND them |
| Tenants + resources | missing either column | deny all |
This is enforced inside modkit-db when you call .scope_with(&scope) / SecureConn::find* / SecureConn::update_many / SecureConn::delete_many.
- Multiple constraints are OR-ed (alternative access paths)
- Filters within a constraint are AND-ed (all must match)
- Unknown properties fail that constraint (fail-closed)
- If all constraints fail resolution → deny-all
let secure_conn = db.sea_secure();
// Automatically adds scope filters
let users = secure_conn
.find::<user::Entity>(&scope)
.all(&secure_conn)
.await?;
// Automatically adds scope + id filter
let user = secure_conn
.find_by_id::<user::Entity>(&scope, user_id)?
.one(&secure_conn)
.await?;use modkit_db::secure::SecureEntityExt;
// For complex queries, build your filters first, then apply scope and execute via SecureConn.
let user = user::Entity::find()
.filter(user::Column::Email.eq(email))
.secure()
.scope_with(&scope)
.one(&secure_conn)
.await?;Use these when the base entity cannot be tenant-filtered directly:
SecureSelect::and_scope_for::<J>(&scope)— apply tenant scoping on a joined entityJ.SecureSelect::scope_via_exists::<J>(&scope)— apply tenant scoping via anEXISTSsubquery onJ.
- If the entity has a
tenant_col, theActiveModelMUST includetenant_id. - The inserted
tenant_idMUST be insidescope.all_values_for(pep_properties::OWNER_TENANT_ID). - Violations are errors (
Denied/TenantNotInScope/Invalid("tenant_id is required")).
- There is no public unscoped update-one API.
update_with_ctx(scope, id, am)first checks the row exists in scope.- For tenant-scoped entities,
tenant_idis immutable. Attempts to change it are denied.
- Must be scoped via
scope_with/SecureConn::update_many(scope). - Attempts to set the
tenant_idcolumn are denied at runtime (Denied("tenant_id is immutable")).
See 11_database_patterns.md for DBRunner/SecureTx, transaction patterns (in_transaction_mapped), the repository pattern, and database migrations.
Map EnforcerError to your domain error type:
use authz_resolver_sdk::EnforcerError;
impl From<EnforcerError> for DomainError {
fn from(e: EnforcerError) -> Self {
tracing::error!(error = %e, "AuthZ scope resolution failed");
match e {
// PDP denied access or constraints failed to compile → 403
EnforcerError::Denied { .. }
| EnforcerError::CompileFailed(_) => Self::Forbidden,
// PDP unreachable or returned invalid response → 500
EnforcerError::EvaluationFailed(_) => Self::InternalError,
}
}
}This mapping follows the fail-closed principle: denial and compilation failures are access errors (403), while evaluation failures are infrastructure errors (500).
ScopeError is returned by SecureConn / SecureTx methods when scope violations occur (e.g., inserting into a tenant not in scope, attempting to change tenant_id). Map these to appropriate domain errors (typically 403 for denied, 500 for DB errors).
For development and testing, Cyber Ware provides static AuthN and AuthZ plugins:
-
Static AuthN plugin — accepts all tokens or maps configured tokens to identities. Modes:
accept_all: any non-empty token maps to a default identitystatic_tokens: specific tokens map to specific identities (subject_id, tenant_id, scopes)
-
Static AuthZ plugin — returns
decision: truewith anInpredicate onowner_tenant_idscoped to the subject's tenant from the request context. Denies if no tenant is resolvable.
The server binary uses feature flags to include static plugins:
# Run with static AuthN + AuthZ plugins (development mode)
cargo run --bin cyberware-server --features static-authn,static-authz -- --config config.yaml run
# Makefile target includes these features
make exampleFor the simplest local development (no auth at all), configure the API Gateway:
modules:
api-gateway:
auth_disabled: trueThis injects a default SecurityContext for all requests without calling any AuthN resolver.
Important: auth_disabled only skips AuthN. If your module depends on authz-resolver, the AuthZ call still happens (using the default SecurityContext from the gateway). Use static AuthZ plugin to provide predictable authorization responses.
use modkit_db::DbHandle;
use modkit_security::AccessScope;
#[tokio::test]
async fn test_user_repository() {
let db = setup_test_db().await;
let scope = AccessScope::for_tenant(Uuid::new_v4());
let repo = UserRepository;
let conn = db.sea_secure();
// Test operations
let user = repo.create(&conn, &scope, new_user).await.unwrap();
let found = repo.find_by_id(&conn, &scope, user.id).await.unwrap();
assert_eq!(found.id, user.id);
}In tests, build scopes explicitly (AccessScope::for_tenant(...), AccessScope::for_tenants(...), AccessScope::for_resources(...)). This is the one place where manual AccessScope construction is appropriate.
- Add
deps = ["authz-resolver"]to your module declaration. - Resolve
AuthZResolverClientfromClientHubininit(). - Create
PolicyEnforcer::new(authz)once, clone into sub-services. - Define
ResourceTypeconstants withsupported_propertiesfor each resource. - Define action constants (
get,list,create,update,delete). - Call
policy_enforcer.access_scope()or.access_scope_with()in every service method. - Implement
From<EnforcerError>for your domain error type. - Use
.authenticated()+.require_license_features::<License>([])on protectedOperationBuilderroutes. - Use
.public()only for truly unauthenticated routes (health checks, OpenAPI spec).
- Derive
Scopableon SeaORM entities withtenant_col(required). - Declare all four dimensions or use
unrestricted. - Add
pep_prop(name = "column")for custom domain properties. - Use
db.sea_secure()for all DB access in handlers/services. - Pass the
AccessScopefromPolicyEnforcertoSecureConnmethods. - Use
secure_conn.find::<Entity>(&scope).all(&secure_conn)for auto-scoped queries. - Use
secure_conn.update_with_ctx::<Entity>(&scope, id, am)for single-record updates. - See
11_database_patterns.mdfor DBRunner, transaction, repository, and migration checklists.
- Use prefetch pattern for GET/UPDATE/DELETE (extract
owner_tenant_idfor narrow PDP constraints). - For CREATE, pass
owner_tenant_idas a resource property. - In tests, build scopes explicitly.
- Full authorization architecture:
docs/arch/authorization/DESIGN.md - Usage scenarios:
docs/arch/authorization/AUTHZ_USAGE_SCENARIOS.md - Database execution patterns (DBRunner, transactions, repos, migrations):
11_database_patterns.md - REST OperationBuilder (
.authenticated(),.public()):04_rest_operation_builder.md - OData pagination / filtering:
07_odata_pagination_select_filter.md - Canonical example:
examples/modkit/users-info/users-info/src/