Skip to content

Latest commit

 

History

History
727 lines (558 loc) · 27 KB

File metadata and controls

727 lines (558 loc) · 27 KB

Security: AuthN, AuthZ & Secure Database Access

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.

Core invariants

  • Rule: Authentication is handled by API Gateway middleware — modules receive a ready-to-use SecurityContext.
  • Rule: Use SecureConn for all DB access in handlers/services. Modules cannot access raw database connections/pools.
  • Rule: Derive Scopable on SeaORM entities with tenant/resource columns.
  • Rule: Use PolicyEnforcer from authz-resolver-sdk for all authorization decisions. Do not construct AccessScope manually 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 with AccessScope::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 EnforcerError to domain errors. Never expose PDP internals to the client.
  • Rule: Use .authenticated() on OperationBuilder for 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.md for migration rules.

Architecture overview

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:

  1. AuthN Resolver — validates bearer tokens, produces SecurityContext (subject identity, tenant, token scopes). Uses the gateway + plugin pattern to delegate to vendor-specific IdPs.
  2. AuthZ Resolver (PDP) — evaluates policies, returns decision + constraints. Uses the gateway + plugin pattern to delegate to vendor-specific authorization services.
  3. Domain modules (PEP) — call PDP via PolicyEnforcer, compile constraints to AccessScope, pass to SecureConn for SQL-level enforcement.

Module developers interact primarily with the PEP layer — the AuthN and AuthZ resolvers are infrastructure modules.

AuthN: how requests get authenticated

Route policies

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 SecurityContext into 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_default config flag.

Extracting SecurityContext in handlers

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))
}

SecurityContext fields

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)

AuthZ: PolicyEnforcer (PEP)

Wiring up

  1. Declare dependency on authz-resolver in your module:
#[modkit::module(
    name = "my_module",
    deps = ["authz-resolver"],
    capabilities = [db, rest],
)]
pub struct MyModule { /* ... */ }
  1. Resolve the AuthZ client from ClientHub during init():
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...
}
  1. Create a single PolicyEnforcer in 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)

Defining resource types

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 resource
  • RESOURCE_ID — the resource's primary key
  • OWNER_ID — the user who owns the resource

Defining actions

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";

PolicyEnforcer API

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.

AccessRequest builder

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);

How AuthZ connects to the database: pep_prop

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).

Scopable entity attributes

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_idColumn::Col Tenant ownership column
resource_col = "col" idColumn::Col Resource identity (usually PK)
owner_col = "col" owner_idColumn::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.

Standard property auto-mapping

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).

Custom properties with pep_prop

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.

The full data flow

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).

pep_prop validation rules

The macro enforces at compile time:

  • Reserved names rejectedowner_tenant_id, id, owner_id cannot be used in pep_prop() (use tenant_col, resource_col, owner_col instead).
  • No duplicates — each property name may appear only once.
  • Empty names rejected — property and column names must be non-empty.
  • Forbidden with unrestrictedpep_prop() cannot be combined with unrestricted.

Unrestricted entities (#[secure(unrestricted)])

Use #[secure(unrestricted)] only for truly global tables where the entity has no scoping columns. Notes:

  • secure_insert does not require tenant_id for 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 (often resources_only).

CRUD authorization patterns

All patterns below are from the canonical users-info example module.

LIST — simple scope

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)
}

GET — prefetch pattern

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 — resource properties

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)
}

UPDATE / DELETE — prefetch + TOCTOU safety

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.

SecureConn usage

Preferred: SecureConn for scoped access

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()))
}

Implicit security policy (how AccessScope becomes SQL)

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.

OR/AND semantics

  • 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

Auto-scoped queries

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?;

Manual scoping

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?;

Advanced scoping for joins / related entities

Use these when the base entity cannot be tenant-filtered directly:

  • SecureSelect::and_scope_for::<J>(&scope) — apply tenant scoping on a joined entity J.
  • SecureSelect::scope_via_exists::<J>(&scope) — apply tenant scoping via an EXISTS subquery on J.

Mutations (security rules)

Insert (secure_insert / SecureConn::insert)

  • If the entity has a tenant_col, the ActiveModel MUST include tenant_id.
  • The inserted tenant_id MUST be inside scope.all_values_for(pep_properties::OWNER_TENANT_ID).
  • Violations are errors (Denied / TenantNotInScope / Invalid("tenant_id is required")).

Update one record (SecureConn::update_with_ctx)

  • 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_id is immutable. Attempts to change it are denied.

Update many (SecureConn::update_many)

  • Must be scoped via scope_with / SecureConn::update_many(scope).
  • Attempts to set the tenant_id column are denied at runtime (Denied("tenant_id is immutable")).

Executors, transactions, repository pattern, and migrations

See 11_database_patterns.md for DBRunner/SecureTx, transaction patterns (in_transaction_mapped), the repository pattern, and database migrations.

Error handling

EnforcerError (AuthZ errors)

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 (ORM errors)

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).

Development setup

Static plugins

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 identity
    • static_tokens: specific tokens map to specific identities (subject_id, tenant_id, scopes)
  • Static AuthZ plugin — returns decision: true with an In predicate on owner_tenant_id scoped to the subject's tenant from the request context. Denies if no tenant is resolvable.

Feature flags

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 example

Config: auth_disabled

For the simplest local development (no auth at all), configure the API Gateway:

modules:
  api-gateway:
    auth_disabled: true

This 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.

Testing with SecureConn

Test setup

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.

Quick checklist

AuthZ wiring

  • Add deps = ["authz-resolver"] to your module declaration.
  • Resolve AuthZResolverClient from ClientHub in init().
  • Create PolicyEnforcer::new(authz) once, clone into sub-services.
  • Define ResourceType constants with supported_properties for 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 protected OperationBuilder routes.
  • Use .public() only for truly unauthenticated routes (health checks, OpenAPI spec).

SecureConn / database

  • Derive Scopable on SeaORM entities with tenant_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 AccessScope from PolicyEnforcer to SecureConn methods.
  • 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.md for DBRunner, transaction, repository, and migration checklists.

CRUD patterns

  • Use prefetch pattern for GET/UPDATE/DELETE (extract owner_tenant_id for narrow PDP constraints).
  • For CREATE, pass owner_tenant_id as a resource property.
  • In tests, build scopes explicitly.

Related docs