23 KiB
Thoughts on ABAC and Consents
ABAC Overview
Attribute-Based Access Control (ABAC) evaluates access based on attributes:
- User attributes:
ABAC_role=teller,ABAC_branch=branch_123,ABAC_clearance_level=2 - Account attributes:
branch=branch_123,account_type=checking,vip_status=gold - Context: time of day, business hours, customer present
Policy example: "Tellers can access accounts where user.branch == account.branch during business hours"
The Challenge: Real Banking Workflows
Banks need staff access patterns like:
- Branch-based: Tellers at branch_123 see accounts at branch_123
- Role-based: VIP account managers see VIP accounts
- Time-limited: Customer service gets 30-minute access during customer interaction
- Session-based: Access expires when session/shift ends
- Dynamic: No manual pre-granting of thousands of permissions
Traditional approaches don't fit:
- Individual AccountAccess grants: doesn't scale (thousands of accounts)
- Firehose: too broad (all accounts at bank)
- Manual grants per request: too slow for operations
Could Consents Work for ABAC?
Instead of real-time policy evaluation on every request, create a consent when ABAC policy matches.
Flow Concept
- User requests account access
- No explicit AccountAccess grant exists
- User has
CanUseABACentitlement - System evaluates ABAC policy (checks attributes)
- Policy matches → Create consent with explicit account list
- Consent valid for short period (15-60 minutes)
- Subsequent requests check consent (fast lookup)
- Consent expires → Re-evaluate policy on next request
Why Consents Could Work
- Time limits built-in (
validFrom,validTo) - Status management (ACCEPTED, REVOKED, EXPIRED)
- Audit trail (creation, usage, expiry all logged)
- Explicit account list in
viewsfield - Can be revoked when attributes change
- Reuses existing infrastructure (no new tables)
- Standard authorization check works (
hasAccountAccess)
Consent as Cache
The consent acts as a cached ABAC decision:
- Real-time evaluation would be slow (fetch attributes, evaluate policy)
- Consent caches: "User X can access accounts Y,Z at branch_123"
- Fast lookup: is requested account in consent's views list?
- Short TTL ensures freshness (15-60 minutes)
- Revoke on attribute change for immediate effect
ABAC Consent vs Standard Consent
Structurally identical:
- Same fields:
userId,views,validFrom,validTo,status - Same table:
MappedConsent - Same authorization logic: lookup in
viewslist - Same usage:
hasAccountAccess()checks for valid consent
Differences:
- Creation: Policy evaluation vs customer authorization
- Lifetime: 15-60 minutes vs 90+ days
- Revocation: Automatic (attribute change) vs manual (customer revokes)
- Initiator: System vs customer
- Purpose: Staff operations vs TPP access
Schema Considerations: Marking ABAC Consents
Need to distinguish ABAC-generated consents for audit/management.
Existing Fields Analysis
issuer (iss in JWT)
- Standard use: JWT issuer for validation (e.g.,
Constant.HostName,"https://accounts.google.com") - Used in OAuth2 flows for token validation
- Should NOT be changed - would break JWT validation logic
- Keep as standard OBP issuer
createdByUserId
- Standard consent: Customer who authorized TPP access
- ABAC consent: User whose attributes matched policy (even though not explicitly authorized by them)
- Semantically fits: "consent for this user, created by system evaluation"
consumerKey (aud in JWT)
- Standard use: OAuth consumer/application
- Should NOT be overloaded to mark ABAC - semantically wrong, could break consumer logic
apiStandard
- Meant for API specifications: "BERLIN_GROUP", "UK_OBWG", "OBP"
- Should NOT be used for creation method - wrong purpose
Option A: Use note Field
note = "ABAC_GENERATED|policy=branch_teller_access|user_branch=branch_123|timestamp=1234567890"
// Query
consents.filter(_.note.contains("ABAC_GENERATED"))
Pros:
- No schema change
- Works immediately
- Can include rich metadata
Cons:
- String parsing needed
- Less structured than proper field
- Queries less efficient
Option B: Add source Field (Recommended)
Add to MappedConsent:
object mSource extends MappedString(this, 50) {
override def defaultValue = "CUSTOMER_GRANTED"
}
override def source: String = mSource.get
// Values:
// - "CUSTOMER_GRANTED" (standard Open Banking)
// - "ABAC_GENERATED" (policy-based)
// - "SYSTEM_GENERATED" (admin/system)
Pros:
- Clean, structured
- Easy to query:
By(MappedConsent.mSource, "ABAC_GENERATED") - Clear semantics
- Future-proof (other generation methods)
Cons:
- Requires database migration
- Changes to consent schema
Migration:
ALTER TABLE mappedconsent ADD COLUMN msource VARCHAR(50) DEFAULT 'CUSTOMER_GRANTED';
CREATE INDEX idx_mappedconsent_source ON mappedconsent(msource);
Implementation Ideas
User Setup with Non-Personal Attributes
Users get ABAC attributes (non-personal):
POST /users/USER_ID/entitlements
{ "role_name": "CanUseABAC", "bank_id": "" }
POST /users/USER_ID/non-personal-attributes
[
{ "name": "ABAC_role", "type": "STRING", "value": "teller" },
{ "name": "ABAC_branch", "type": "STRING", "value": "branch_123" },
{ "name": "ABAC_department", "type": "STRING", "value": "retail" },
{ "name": "ABAC_clearance_level", "type": "INTEGER", "value": "2" }
]
Naming convention: ABAC_ prefix distinguishes control attributes from business attributes.
Account Setup
POST /banks/BANK_ID/accounts/ACCOUNT_ID/attributes
[
{ "name": "branch", "type": "STRING", "value": "branch_123" },
{ "name": "account_type", "type": "STRING", "value": "checking" },
{ "name": "vip_status", "type": "STRING", "value": "gold" },
{ "name": "customer_id", "type": "STRING", "value": "customer_456" }
]
Access Flow Sketch
def hasAccountAccess(view, bankIdAccountId, user, callContext): Boolean = {
// Standard checks first
if (isPublicView(view)) return true
if (hasAccountFirehoseAccess(view, user)) return true
if (user.hasExplicitAccountAccess(view, bankIdAccountId, callContext)) return true
// ABAC check
if (hasEntitlement(user.userId, "CanUseABAC")) {
// Check for existing ABAC consent with this account
val existingConsent = getABACConsents(user.userId).find { c =>
c.source == "ABAC_GENERATED" && // If we add source field
c.views.exists(cv => cv.account_id == bankIdAccountId.accountId.value) &&
c.validTo > now &&
c.status == "ACCEPTED"
}
if (existingConsent.isDefined) return true // Fast path: cached
// No cached consent, evaluate ABAC policy
val decision = evaluateABACPolicy(user, bankIdAccountId, view)
if (decision.allowed) {
// Find all accounts matching same policy pattern
val matchingAccounts = findAccountsMatchingPolicy(user, decision)
// Create consent with explicit account list
createABACConsent(
user = user,
accounts = matchingAccounts, // Explicit list in views
viewId = decision.viewId,
validTo = now + decision.durationMinutes.minutes,
source = "ABAC_GENERATED",
note = s"Policy: ${decision.policyName}, Reason: ${decision.reason}"
)
return true
}
}
false
}
Example Policies
Branch Teller:
if (user.ABAC_role == "teller" &&
user.ABAC_branch == account.branch &&
account.account_type in ["checking", "savings"] &&
isBusinessHours) {
grant(duration = 60.minutes, view = "teller")
}
VIP Account Manager:
if (user.ABAC_role == "vip_account_manager" &&
account.vip_status in ["gold", "platinum"]) {
grant(duration = 240.minutes, view = "owner")
}
Customer Service Session:
if (user.ABAC_role == "customer_service" &&
user.ABAC_customer_session_active == account.customer_id &&
sessionAge < 30.minutes) {
grant(duration = 30.minutes, view = "customer_service")
}
Branch Manager:
if (user.ABAC_role == "branch_manager" &&
user.ABAC_branch == account.branch) {
grant(duration = 480.minutes, view = "owner")
}
Compliance Officer:
if (user.ABAC_role == "compliance_officer" &&
user.ABAC_clearance_level >= 4) {
grant(duration = 480.minutes, view = "auditor")
}
Attribute Changes and Revocation
Hook into attribute deletion/update:
override def deleteUserAttribute(userId: String, attributeName: String): Box[Boolean] = {
val result = super.deleteUserAttribute(userId, attributeName)
if (attributeName.startsWith("ABAC_")) {
// Revoke all ABAC-generated consents for this user
getABACConsents(userId).foreach { consent =>
consent.mStatus("REVOKED")
.mNote(s"${consent.note}|AUTO_REVOKED: ${attributeName} removed at ${now}")
.save()
}
}
result
}
This ensures attribute changes take immediate effect (even if consent hasn't expired yet).
Pattern-Based vs Explicit Account List
Two approaches for what goes in consent.views:
Explicit List (Recommended)
Consent contains actual account IDs:
views = List(
ConsentView("bank-123", "account-001", "teller"),
ConsentView("bank-123", "account-002", "teller"),
// ... 50 more accounts at branch_123
)
Pros:
- Fast lookup: is account in list?
- Works with existing consent logic
- Clear audit: see exactly which accounts
Cons:
- Large list if many accounts (50-100+)
- Must re-create if new accounts added
Pattern-Based
Consent stores attribute pattern, not account IDs:
views = List(), // Empty
abac_pattern = Map(
"user_branch" -> "branch_123",
"account_branch" -> "branch_123",
"account_type" -> "checking,savings"
)
On each access, fetch account attributes and check against pattern.
Pros:
- Small consent record
- Automatically includes new accounts
- More flexible
Cons:
- Must fetch account attributes on each check
- Custom evaluation logic needed
- More complex
Recommendation: Start with explicit list. If >100 accounts per consent becomes common, consider pattern-based.
Real-Time vs Cached Evaluation
Pure ABAC (Real-time):
Request → Fetch user attributes → Fetch account attributes →
Evaluate policy → Allow/Deny
- Always current
- Slower (fetch + evaluate each time)
- No consent records
Consent-Cached ABAC:
Request → Check consent exists → Found? Allow (fast)
→ Not found? → Evaluate → Create consent → Allow
- Fast (list lookup)
- Short TTL (15-60 min) keeps it fresh
- Revoke on attribute change for immediate effect
- Full audit trail
Hybrid (could be interesting):
Request → Check consent → Valid? → Validate attributes still match → Allow/Re-evaluate
- Cache for performance
- Validate attributes on each use for freshness
- Best of both worlds but more complex
Customer Service Workflow Idea
POST /customer-service/session/start
{
"customer_number": "CUST-123",
"reason": "Customer requesting balance info"
}
// Backend:
// 1. Verify user has ABAC_role=customer_service
// 2. Find customer's accounts
// 3. Create temporary user attribute: ABAC_customer_session_active=CUST-123
// 4. Create ABAC consent for customer's accounts (30 min)
// 5. Return session_id
Response:
{
"session_id": "session-abc",
"customer_id": "customer-456",
"account_ids": ["acc-1", "acc-2"],
"expires_at": "2024-01-15T10:30:00Z"
}
// User accesses accounts using normal endpoints (no special headers)
GET /banks/bank-123/accounts/acc-1/owner/account
// Works because ABAC consent exists
POST /customer-service/session/end
{
"session_id": "session-abc"
}
// Backend:
// 1. Remove ABAC_customer_session_active attribute
// 2. Revoke ABAC consents for this session
Clean workflow, time-bound, full audit trail.
Endpoints: Do We Need New Ones?
No new account endpoints needed - existing endpoints work transparently because ABAC integrates into hasAccountAccess().
But might want management endpoints:
// List my active ABAC consents
GET /my/abac-consents
Response: List of consent IDs, accounts, expiry times
// Revoke ABAC consent (early)
DELETE /consents/{CONSENT_ID}
// Existing endpoint already works
// Customer service workflow helper
POST /customer-service/session/start
POST /customer-service/session/end
// Admin: view ABAC usage
GET /admin/abac-consents?user_id=X&date_from=Y
GET /admin/abac-policies // List active policies
Machine Learning Integration Ideas
Track ABAC consent usage and apply ML for anomaly detection:
Normal Patterns
- Teller at branch_123 accesses 20-40 accounts/day, Mon-Fri 9am-5pm
- Customer service sessions average 3 accounts, duration 15 minutes
- Branch manager accesses 50-100 accounts/day during business hours
Anomalies to Detect
- Time anomaly: Teller accessing accounts at 2am
- Volume anomaly: Teller accessing 200 accounts in one day
- Scope anomaly: Teller accessing accounts at different branch
- Pattern anomaly: Customer service session lasting 4 hours
- Sequence anomaly: Rapid access to VIP accounts by new user
ML Approach
Features:
- Time of day
- Day of week
- Number of accounts accessed
- Duration of consent usage
- User role
- Account types accessed
- Deviation from user's normal pattern
- Deviation from role's normal pattern
Model: Isolation Forest or Autoencoder
Output: Anomaly score (0-1)
Action:
- Score > 0.8: Alert security, revoke consent
- Score 0.5-0.8: Flag for review
- Score < 0.5: Normal
Could even auto-revoke consents that trigger anomaly detection.
Configuration Ideas
# Enable ABAC
enable_abac=true
# Auto-create consents when policy matches
abac.auto_consent_enabled=true
# Policy durations (minutes)
abac.teller_duration=60
abac.manager_duration=480
abac.customer_service_duration=30
abac.compliance_duration=480
# Business rules
abac.business_hours_start=9
abac.business_hours_end=17
abac.require_customer_present_for_cs=true
# Consent management
abac.cleanup_expired_enabled=true
abac.cleanup_interval_minutes=15
abac.max_accounts_per_consent=100
abac.revoke_on_attribute_change=true
# Security
abac.max_consents_per_user_per_day=50
abac.alert_on_excessive_consent_creation=true
abac.ml_anomaly_detection_enabled=false
# Audit
abac.log_all_evaluations=true
abac.log_denied_attempts=true
Context Mutation Concerns: Should ABAC Auto-Generate Consents?
An important architectural question: Should the ABAC system automatically generate consents during request processing, or should consent generation be explicit?
The Context Mutation Problem
Proposed Flow:
- User calls endpoint with OAuth2/OIDC header +
CanUseAbacrole - During request processing, ABAC evaluates policies
- If policy matches, system creates a consent
- Consent gets attached to current call context
- Request proceeds using the newly-created consent
Why This Is Problematic:
1. Violates Principle of Least Surprise
- Caller makes request with OAuth2 credentials
- Behind the scenes, system creates a persistent consent entity
- This implicit behavior makes debugging and understanding the system harder
- Side effects hidden from the caller violate transparency
2. Semantic Confusion
- Consents traditionally represent explicit user agreements ("I consent to share my data")
- ABAC evaluations are policy-based decisions ("Your attributes match policy criteria")
- Mixing these concepts muddies the semantic waters
- For compliance/regulatory purposes, this distinction matters
3. Lifecycle Management Complexity
- When should auto-generated consents expire?
- How do you clean them up?
- What happens if attributes change mid-request?
- Does the consent persist beyond the current call?
- Creates timing issues and potential race conditions
4. Audit Trail Ambiguity
- Was this consent user-initiated or system-generated?
- Who authorized it - the user or the policy engine?
- Compliance systems need clear distinctions
Alternative Approaches
Option 1: ABAC as Pure Policy Evaluation (Recommended)
Keep ABAC as a transparent evaluation layer without side effects:
def checkAccess(user: User, resource: Resource, action: Action): Boolean = {
val attributes = gatherAttributes(user, resource, action)
val policies = findApplicablePolicies(resource, action)
evaluatePolicies(policies, attributes) match {
case PolicyResult.Allow(reason) =>
auditLog.record(s"ABAC allowed: $reason")
true
case PolicyResult.Deny(reason) =>
auditLog.record(s"ABAC denied: $reason")
false
}
}
The ABAC evaluation should be:
- Fast (in-memory policy evaluation)
- Stateless (no persistent side effects)
- Auditable (logged but not persisted as consent)
- Transparent (clear in logs, but invisible to caller)
Option 2: ABAC Evaluation Result as Request Metadata
Instead of creating a consent, attach the evaluation result to request context:
case class RequestContext(
oauth2Token: Token,
userRoles: Set[Role],
abacEvaluation: Option[AbacEvaluationResult] = None
)
case class AbacEvaluationResult(
decision: Decision,
matchedPolicies: List[Policy],
evaluatedAttributes: Map[String, String],
evaluatedAt: DateTime,
validUntil: DateTime
)
This allows:
- Caching evaluation results within request scope
- Passing results to downstream services
- Audit logging without persistence
- No database writes on every request
Option 3: Explicit Two-Step Flow
If you must use consents, make it explicit:
# Step 1: Acquire ABAC Consent (explicit call)
POST /consents/abac
Authorization: Bearer {oauth2_token}
{
"resource_type": "account",
"resource_id": "123",
"action": "view_balance"
}
Response:
{
"consent_id": "abac-consent-xyz",
"valid_until": "2024-01-15T10:30:00Z",
"granted_accounts": ["123", "456", "789"]
}
# Step 2: Use the Consent (separate call)
GET /accounts/123/balance
Authorization: Bearer {oauth2_token}
X-ABAC-Consent: abac-consent-xyz
Option 4: Consent as Cache (Current Document Approach)
The approach described in this document treats consents as a cache for ABAC decisions:
- First request: No consent exists → Evaluate ABAC → Create consent → Use it
- Subsequent requests: Consent exists → Skip evaluation → Use cached decision
- Consent expires: Back to evaluation on next request
This is a middle ground but still has mutation concerns:
- ✅ Reuses existing infrastructure
- ✅ Provides caching benefits
- ✅ Creates audit trail
- ⚠️ Still creates side effects on first request
- ⚠️ Requires cleanup/garbage collection
- ⚠️ Database writes on policy evaluation
When Context Enrichment Is Acceptable
Some forms of context modification are fine:
- Adding computed attributes (in-memory, non-persistent)
- Attaching evaluation results for downstream use within request
- Caching policy decisions within request scope
- Adding trace/correlation IDs for debugging
But creating persistent entities (like database records) crosses from enrichment to mutation with side effects.
Recommendation
For production ABAC implementation:
- Evaluation Phase: ABAC evaluates policies (fast, stateless)
- Authorization Phase: Result determines allow/deny
- Audit Phase: Log decision with context
- No Consent Generation: Unless explicitly requested
If caching is needed:
- Use in-memory cache (Redis, Memcached)
- Cache evaluation results, not consents
- Clear cache on attribute changes
- TTL matches policy freshness requirements
If consents are truly needed:
- Make acquisition explicit (separate endpoint)
- Document the semantic difference from user consents
- Implement clear lifecycle management
- Provide revocation endpoints
Summary: Implicit vs Explicit
| Aspect | Implicit (Auto-Generate) | Explicit (Separate Call) |
|---|---|---|
| Caller experience | Simple, transparent | Two-step, more complex |
| Debugging | Harder, hidden behavior | Easier, clear flow |
| Performance | Better (caching built-in) | Requires separate cache |
| Side effects | Yes (database writes) | Only when requested |
| Semantic clarity | Confused | Clear |
| Audit trail | Ambiguous source | Clear initiator |
| Recommendation | ❌ Avoid | ✅ Prefer if using consents |
Challenges and Open Questions
-
Schema change: Add
sourcefield or usenote?sourcefield cleaner but requires migrationnotefield works now but less structured
-
Large account lists: What if teller has access to 500 accounts?
- One consent with 500 views?
- Multiple consents (e.g., 100 accounts each)?
- Pattern-based consent?
-
Consent lifetime: Balance between performance and freshness
- 15 min: more real-time, less caching benefit
- 30 min: balanced
- 60 min: more caching, less fresh
-
Policy storage: Hard-coded in Scala or database?
- Hard-coded: simpler, requires deployment to change
- Database: flexible, could have UI, more complex
-
View selection: Does policy specify which view to grant?
- Yes: policy says "grant teller view" (more controlled)
- No: use requested view (more flexible)
-
Consent in request header:
- Standard consent: TPP includes Consent-JWT in header
- ABAC consent: No header needed, just CanUseABAC entitlement
- This works because consent is cached authorization, not passed token
-
Attribute sync: What if account attributes change after consent created?
- Wait for consent expiry (simpler)
- Re-validate on each use (more accurate, more complex)
- Depends on attribute change frequency
-
Multi-bank: Can CanUseABAC work across banks?
- Bank-specific: CanUseABAC at bank-123
- Global: CanUseABAC at any bank
- Mix: Some users global, some bank-specific
Related: Existing OBP Attributes
OBP already has attributes on:
- Users: UserAttribute (personal and non-personal)
- Accounts: AccountAttribute
- Transactions: TransactionAttribute
- Products: ProductAttribute
- Customers: CustomerAttribute
ABAC can leverage all of these. Example:
// Transaction-level ABAC
if (user.ABAC_role == "fraud_investigator" &&
transaction.amount > 10000 &&
transaction.TransactionAttribute("suspicious") == "true") {
grant(duration = 120.minutes, view = "auditor")
}
The attribute infrastructure is already there - ABAC is about using it for access decisions.
Summary of Approach
- User has
CanUseABACentitlement - User has non-personal ABAC attributes (
ABAC_role,ABAC_branch, etc.) - Accounts have attributes (
branch,account_type, etc.) - When user requests account access:
- Check for valid ABAC consent first (fast)
- If not found, evaluate ABAC policy
- If policy matches, create consent with explicit account list
- Consent valid 15-60 minutes
- Consent contains
source = "ABAC_GENERATED"(if we add field) or marker innote - When user's ABAC attributes change, revoke their ABAC consents
- Full audit trail via consent records
- Optional: ML anomaly detection on usage patterns
This reuses consent infrastructure while providing dynamic, attribute-based access control suitable for bank staff workflows.