ABAC Conditions
ABAC conditions (time-of-day, IP, fresh-MFA gates) are part of the planned authorisation stack. Today's Omnitron does not evaluate request-attribute predicates; the policy shapes below are the target design.
When a role + permission check isn't enough — when access also depends on when, where, or how recently authenticated the caller is — write an ABAC policy.
A PolicyDefinition is a named, async, side-effect-free
function that takes the request's ExecutionContext and returns
a PolicyDecision.
interface PolicyDefinition {
name: string;
description?: string;
tags?: string[];
evaluate: (ctx: ExecutionContext) => Promise<PolicyDecision> | PolicyDecision;
}
interface PolicyDecision {
allowed: boolean;
reason?: string;
}
Built-in policies
BuiltInPolicies ships the common shapes:
| Factory | Purpose |
|---|---|
requireAuth() | Reject anonymous callers |
requireRole(role) | Single role required |
requireAnyRole([…]) / requireAllRoles([…]) | Combine multiple roles |
requirePermission(perm) | Single permission required (wildcard-aware) |
requireAnyPermission([…]) | Any-of |
requireResourceOwner() | auth.userId === resource.owner |
requireTimeWindow(start, end, tz?) | Time-of-day window |
requireIP([…]) / blockIP([…]) | IP allow / block lists |
requireAttribute(path, value) | Arbitrary auth.metadata[path] === value match |
requireScope(scope) / requireAnyScope([…]) | OAuth2/OIDC scope |
rateLimit(maxRequests, windowMs) | Per-caller rate limit (policy returns deny when over budget) |
requireTenantIsolation() | Multi-tenant invariant: actor's tenant === resource's tenant |
requireEnvironment(env) | Only allow in a specific deploy environment |
requireFeatureFlag(flag, enabled?) | Gate behind a feature flag |
Custom policies
A custom policy is a plain object — register it on the
PolicyEngine and reference it by name from @Auth:
// my-policies.ts
import type { PolicyDefinition } from '@omnitron-dev/titan/netron/auth';
export const requireRecentMfa = (maxAgeSeconds: number): PolicyDefinition => ({
name: `recent-mfa:${maxAgeSeconds}s`,
description: `Caller must have stepped up MFA within the last ${maxAgeSeconds} seconds`,
tags: ['mfa', 'step-up'],
evaluate: (ctx) => {
const stepUpAt = (ctx.auth?.metadata as { stepUpAt?: number } | undefined)?.stepUpAt;
if (!stepUpAt) return { allowed: false, reason: 'No step-up MFA in session' };
const ageMs = Date.now() - stepUpAt;
if (ageMs > maxAgeSeconds * 1000) {
return { allowed: false, reason: `Step-up MFA expired (${Math.floor(ageMs / 1000)}s ago)` };
}
return { allowed: true };
},
});
// app bootstrap
policyEngine.registerPolicy(requireRecentMfa(300));
// service
@Auth({ policies: ['recent-mfa:300s'] })
async transferLargeAmount(...) { … }
Composition
// AND (default)
@Auth({ policies: ['role:admin', 'recent-mfa:300s'] })
// OR
@Auth({ policies: { any: ['role:admin', 'role:security'] } })
// NOT
@Auth({ policies: { not: 'block-ip:tor-exits' } })
// Nested
@Auth({
policies: {
and: [
'role:admin',
{ or: ['recent-mfa:300s', 'feature-flag:mfa-bypass'] },
],
},
})
The engine short-circuits on the first decisive branch — AND fails as soon as one leaf denies, OR succeeds as soon as one allows.
Failure semantics
PolicyDecision.reason is captured into the audit-log alongside
the false decision so the operator sees which gate denied and
why. Surface the reason to admins via the same audit-log UI
(see Audit trail); never echo it back to
the caller — it's an information leak.
Per-call configuration
A few of the built-ins take parameters specific to the gate;
prefer the factory form in @Auth:
@Auth({
policies: [
BuiltInPolicies.rateLimit(100, 60_000),
BuiltInPolicies.requireTimeWindow('09:00', '18:00', 'Europe/Berlin'),
BuiltInPolicies.requireFeatureFlag('beta-checkout-v2', true),
],
})
These don't need to be pre-registered on the engine — Titan materialises and caches them per-method at decoration time.
See also
- Permissions — the simpler permission-only gate that covers ~80% of cases.
- Per-user overrides — additive grants on top of role-derived permissions.
- Audit trail — every policy decision is audit-logged with the deny reason.