RBAC says what a subject can normally do. ABAC constrains that ability with resource attributes. ReBAC supplies instance-level relation evidence. Feature says whether a tenant owns the capability. They answer different questions and the decision service merges them.
1. One case, four kinds of evidence
Alice has billing.invoice.approve. She is the Approver of an Open, Confidential invoice worth 120,000, but the tenant lacks a required entitlement.
Two Allows cannot cancel either Deny. This is not voting, and no implicit relation-over-attribute hierarchy exists.
2. ABAC supports a narrow resource vocabulary
Each current ABAC rule has one condition. Rules are ordered by ascending Priority, filtered by Action, and the first matching condition returns its Verdict.
2.1 Fields and operators
| Field | Value | Resource source | Supported operators |
|---|---|---|---|
| Amount | 0 | ResourceDescriptor.Amount | greater/less, greater/less or equal, equals, not equals |
| Status | 1 | ResourceDescriptor.Status | Equals, NotEquals, In |
| Sensitivity | 2 | ResourceDescriptor.Sensitivity | Equals, NotEquals, In |
| CreateTime | 3 | No corresponding resource value | Enum exists, but evaluation always returns false |
| Operator | Value | Meaning |
|---|---|---|
| GreaterThan / LessThan | 0 / 1 | Numeric Amount only |
| GreaterThanOrEqual / LessThanOrEqual | 2 / 3 | Numeric Amount only |
| Equals / NotEquals | 4 / 5 | Amount and text fields |
| In | 6 | Text only; comma-separated values are trimmed and compared case-insensitively |
Verdict uses Allow=0 and Deny=1. A null ActionType means every action; otherwise it is an integer AuthorizationAction value.
2.2 Deny high-value approval
# Action=4 is Approve, Field=0 is Amount, Operator=0 is GreaterThan, Verdict=1 is Deny.# The value uses invariant culture, and a lower Priority number evaluates first.curl --fail-with-body --request POST "$API_URL/api/authorization/abac-rules" \ --header "Authorization: Bearer $ACCESS_TOKEN" \ --header "Content-Type: application/json" \ --data '{ "module": "billing", "resourceType": "invoice", "actionType": 4, "conditionField": 0, "conditionOperator": 0, "conditionValue": "50000", "verdict": 1, "priority": 10 }'Amount parsing uses invariant culture. Use 50000.00, not a localized currency string. Successful ABAC CRUD invalidates the effective tenant’s Permission decision cache so a new Deny is not hidden by an old Allow.
2.3 First ABAC match, not automatic internal Deny priority
Global merge gives Deny priority, but the ABAC evaluator returns only its first match:
[Fact]public async Task Lower_Priority_Number_Should_Deny_Before_Broad_Allow(){ // Both rules match; Priority 10 must run before Priority 100. // The later broad Allow proves ABAC returns its first match rather than collecting all rules. ruleStore.Rules = [ Rule("deny-high-value", amount: "50000", PolicyVerdict.Deny, priority: 10), Rule("allow-general", amount: "10000", PolicyVerdict.Allow, priority: 100), ];
var result = await evaluator.EvaluateAsync( User(), new ResourceDescriptor("billing", "invoice", Amount: 120_000m), AuthorizationAction.Approve, CancellationToken.None);
result.Verdict.Should().Be(PolicyVerdict.Deny); result.Reason.Should().Be("abac:rule:deny-high-value");}Do not rely on database row order for conflicting equal-priority rules. Assign reviewable priorities and test critical precedence.
ABAC returns Neutral for no rules or no match, Deny with abac:store-unavailable for a non-cancellation store exception, and lets cancellation propagate. A missing Amount or invalid numeric rule value simply does not match.
3. ReBAC Lite requires an action-compatible relation
ReBAC participates only for Case, Client, and Billing resource types, compared case-insensitively. It also requires UserId and ResourceId; collection decisions are Neutral.
| RelationType | Allowed actions |
|---|---|
| Owner | View, Create, Update, Delete, Approve, Reject, Export, Import, Assign, Transfer, Search, Manage |
| Collaborator | View, Update, Search |
| Agent | View, Create, Update, Export, Import, Assign, Transfer, Search |
| Approver | View, Approve, Reject |
| Unknown or differently cased value | None |
RelationStore selects an active, non-deleted relation in the trusted tenant and applies ReBacRelationPolicy.Allows. A relation that does not allow the action becomes Neutral rather than Deny.
var resource = new ResourceDescriptor( Module: "cases", ResourceType: "Case", ResourceId: caseFile.Id, TenantId: currentTenant.Id, OwnerUserId: caseFile.OwnerUserId, DepartmentId: caseFile.DepartmentId, Status: caseFile.Status, Sensitivity: caseFile.Sensitivity);
// An Approver relation may allow Approve, but cannot allow Delete.// Trusted tenant and resource IDs prevent a client from forging the relation lookup key.var decision = await authorization.EvaluateAsync( currentUser.User, resource, AuthorizationAction.Approve, cancellationToken);3.1 Relation caching and write ownership
The key contains tenant, resource type and ID, user, and action. Both positive and negative results use the 60-second Short policy and a resource tag. Every relation write must call IReBacCache.InvalidateByResourceAsync.
Authorization currently exposes a relation read store but no create, update, or delete command and no module-local resource invalidation call. A resource owner that writes SysResourceRelation must own a reviewed write protocol and cache invalidation; direct table writes can leave stale Allow and stale Neutral results.
4. Feature default and tenant override
Feature is an entitlement, not another name for permission.
The evaluator maps only:
| Module | FeatureCode |
|---|---|
tickets | platform.tickets |
chat | platform.chat |
workflow | platform.workflow |
reporting | platform.reporting |
Other defined features such as notifications, webhooks, and files do not participate in this evaluator. The current seed contains platform.tickets, platform.chat, platform.workflow, and platform.reporting, and the Demo tenant has explicit workflow/reporting overrides. A genuinely missing definition still resolves to false and denies.
# IsTenantScoped decides whether this writes a tenant override or global default.curl --fail-with-body --request PUT \ "$API_URL/api/authorization/features/platform.tickets" \ --header "Authorization: Bearer $ACCESS_TOKEN" \ --header "Content-Type: application/json" \ --data '{ "isEnabled": true }'Tenant-scoped definitions upsert current TenantId + FeatureCode and invalidate that tenant. Global definitions update DefaultState and call InvalidateAllAsync. Both publish FeatureChangedIntegrationEvent. Keep multi-tenant and multi-instance tests to protect the distinction between tenant-local and global convergence.
Feature returns Neutral for an unmapped module, Deny for missing/disabled definitions, and Deny when the standard Store catches a query exception and returns false. UnavailableFeatureDecisionProvider also returns false. The standard cache adapter degrades get/set exceptions; a custom throwing provider or cache can fault evaluation.
5. Combined-policy tests
Do not stop at evaluator unit tests. Add decision-level scenarios where RBAC always Allows while attributes or entitlement change:
[Theory][InlineData(120000, true, false)][InlineData(1000, false, false)][InlineData(1000, true, true)]public async Task Invoice_Approval_Should_Combine_All_Policies( decimal amount, bool featureEnabled, bool expectedAllowed){ // RBAC remains constant; only resource attributes and tenant entitlement vary. // The rows fix ABAC denial, Feature denial, and the final Allow independently. var user = UserWithPermission("billing.invoice.approve"); featureProvider.Set("platform.billing", featureEnabled); abacRules.SetHighValueDeny(threshold: 50_000m);
var decision = await service.EvaluateAsync( user, new ResourceDescriptor("billing", "invoice", Amount: amount), AuthorizationAction.Approve, CancellationToken.None);
decision.IsAllowed.Should().Be(expectedAllowed);}This is the intended test shape. The current Feature evaluator has no billing mapping, so implement it with an existing tickets case or review and deliver a billing mapping first.
6. Policy review checklist
- Do tenant, amount, status, and sensitivity come from trusted server data?
- Are ABAC priorities unique and covered by first-match tests?
- Is any rule using unsupported CreateTime, an invalid pair, or localized numeric data?
- Who owns ReBAC writes and resource-level invalidation?
- Do every Feature mapping and seed definition exist together?
- Are explicit Deny, all Neutral, store failure, cache failure, and cancellation tested separately?
Previous: RBAC lifecycle · Next: configuration and persistence