aegis-authz-access-control
Detects broken function-level and object-level authorization (BFLA/BOLA), vertical and horizontal privilege escalation, tenant-isolation failures, path/header bypasses, and unsafe delegation across mainstream web frameworks and APIs.
How do I install this agent skill?
npx skills add https://github.com/as-foss/aegis-skills --skill aegis-authz-access-controlIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill is a specialized tool for auditing authorization security in web applications. It provides a structured approach for detecting broken access control, including BOLA and BFLA. The skill itself contains no malicious code; however, because it is designed to ingest and analyze untrusted source code and configurations, it inherently possesses a vulnerability surface for indirect prompt injection attacks originating from the audited codebase.
- Socketwarn
1 alert: gptSecurity
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
Scope & Objectives
Scan for authorization failures: code decides wrongly who may call an action (BFLA, vertical escalation) and which object instances an action may touch (BOLA/IDOR, horizontal escalation).
In scope:
- Missing or incorrect role/permission checks on routes, handlers, resolvers, controllers.
- Object fetches keyed by request-supplied IDs without ownership/tenant predicates.
- Multi-tenant isolation gaps (tenant context from input, shared caches/queues/indexes).
- Authorization logic bugs: deny-by-absence, OR-condition typos, verb tampering.
- Path normalization and proxy-header bypasses (
X-Original-URL,X-Forwarded-Fortrust). - Impersonation/API-key/service-trust features without scoping or audit.
Out of scope (other modules): authentication strength, session fixation, injection, SSRF, crypto. Only audit post-authentication decision logic here; flag unauthenticated reachability of privileged actions as BFLA.
Prerequisites & Vocabulary
Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.
- BFLA: reaching an admin-only action because the route never checks your role
- BOLA / IDOR: changing an identifier in the request (order 1042 → 1043) to touch another user's object
- horizontal escalation: acting on someone else's data while staying at your own privilege level
- vertical escalation: doing things reserved for a higher role (ordinary user → admin)
- tenant: one customer's isolated slice of a shared multi-customer system
- deny-by-absence: when no explicit allow rule exists, the answer must be deny — a missing check is a finding, not "probably fine"
- taint flow / source→sink: path untrusted data travels from entry point to dangerous function
- finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md
Mental Model
Evaluate every route along two independent axes; a route is safe only if BOTH pass.
| Axis | Question | Failure | Primary CWE |
|---|---|---|---|
| Function (action) | May this subject invoke this endpoint at all? | BFLA / vertical escalation | CWE-862, CWE-863 |
| Object (instance) | Given yes above, may it touch THIS row/file/tenant? | BOLA / IDOR / cross-tenant leak | CWE-639 |
| Attribute | May it read/write THESE fields on the object? | Excessive data exposure, mass assignment | CWE-285 |
Decision procedure per route:
- Identify the subject source: session, verified JWT sub claim, API key, mTLS identity. Anything else (query param, header like
user_id, client-stored role) is a red flag by itself. - Identify required roles/actions from business naming (
admin,/users,/internal, DELETE). - Locate where the object is fetched and whether the query embeds the subject (
WHERE id = ? AND tenant_id = ?). - Locate the decision point (middleware/decorator/annotation/policy) and confirm it runs BEFORE any state change or data return.
- Default answer when no explicit allow rule exists: deny. Absence of a check = finding, not "probably fine."
Two-account intuition: every endpoint must behave identically whether the caller owns the object or not — except for the owner, who alone gets data.
What To Check
Function-Level Authorization (BFLA)
- Enumerate every mutating route (POST/PUT/PATCH/DELETE) and every
/admin,/internal,/managesubtree; verify each carries a guard that reads the server-side identity, not a client claim verbatim. - Flag "security by obscurity": hidden UI buttons with no server check; SPA routes rendered only for admins while backing endpoints are open; endpoints whose only defense is an unguessable path segment.
- Check HTTP-method asymmetry: a controller guarded for GET but exposing DELETE/PATCH on the same path via a separate handler or catch-all (
app.all('/admin/x', ...)without guard). - Compare API versions: v1 module annotated/guarded, duplicated v2 controller forgotten. Diff guard counts per version directory.
- Test registration/update payloads for role mass assignment (
"role":"admin","isAdmin":true) accepted into the user record. - Inspect debug/internal surfaces: Spring Actuator, Django admin + DEBUG views, Flask debugger, Go
pprof, ASP.NET detailed errors, GraphQL introspection + mutations in production.
Object-Level Authorization (BOLA/IDOR)
- Trace each request parameter named
id|uuid|guid|uid|email|slug|refinto the first DB/document lookup; require the predicateowner_id == subject.idortenant_id == subject.tenant_idIN THE QUERY, not after it. - Flag collection/list endpoints returning
.all()/find()/Model.objects.all()serialized wholesale (mass listing leaks other tenants' rows). - Nested resources: for
/org/:orgId/doc/:docId, verify BOTHdoc.orgId == orgIdAND caller membership inorgId; membership check on the parent alone is insufficient. - Export/report/download/CSV/PDF/streaming endpoints: these routinely skip guards because they bypass normal serializers. Grep for
export|download|report|dump|backup|csv|xlsx. - GraphQL: per-resolver object checks (not just a top-level auth directive), especially on nested fields (
invoice.customer.address) and DataLoader/batch loaders that fetch by ID arrays. - Sequential IDs (
AutoField,serial,IDENTITY(1,1), incrementing numeric JSON ids): note as exploitability amplifier; UUIDs are mitigation only, never a substitute for predicates. - Property-level granularity: field-level BOLA — record access correct but sensitive columns (
salary,role, SSN-class fields) serialized to callers lacking need-to-know; diff serializer output per caller role. - Mass assignment crossover: client-writable privileged fields (
"role":"admin") are property-level authorization failure too; cross-ref the API module's mass-assignment class when spotted here.
Vertical & Horizontal Escalation
- Roles trusted from client storage: JWT claims (
role,isAdmin,permissions) accepted without re-deriving from the authoritative store on sensitive calls;localStorage.getItem('role')gating UI-only. - Forced browsing to admin panels: static admin bundles served publicly (
express.static('admin')), framework admin at default paths with weak/no auth. - Version-skew escalation: legacy
/api/v1/admin/*kept alive after v2 hardening; old microservice copies of a moved endpoint still deployed. - Cross-tenant context:
tenant_idtaken from request body/query/subdomain instead of the authenticated principal's record;Host-based tenancy spoofable via direct IP access or Host header override. - Shared infrastructure keys: cache keys lacking tenant prefix (
cache.get("invoice:"+id)), global search indexes, queue messages consumed without tenant re-check, temp files named by raw ID in shared dirs.
Logic, Header & Path Bypasses
- Allow vs deny style: find checks written as blocklists (
if (role === 'banned') return) — invert them mentally; new roles inherit access silently. - Boolean typos:
role === 'admin' || userId === idgranting admins everywhere is correct, butrole !== 'admin' || userId !== idinside negations, or&&/||swaps, produce accidental allows. Flag any compound condition mixing role equality and identity equality. - Path normalization: routing decisions made on raw URLs before decode →
%2f,//,/./, trailing dots,;jsessionid, double encoding (%252f) splitting guard path from handler path. - Verb tampering: guard attached per-method; try
HEAD/OPTIONS/TRACEor method overrides (X-HTTP-Method-Override: DELETE,_method=DELETEform param). - Proxy headers honored by app code:
X-Original-URL,X-Rewrite-URL,X-Forwarded-Host,X-Forwarded-Protoused in routing/access decisions;X-Forwarded-Forparsed for IP allowlists (req.headers['x-forwarded-for'].split(',')[0]) — spoofable unless app sits behind a trusted proxy configured to strip/set it. - Framework-specific: ASP.NET
[AllowAnonymous]on a controller overriding global fallback; Laravel$this->middleware()missing in constructor; Railsskip_before_action :authenticate_user!.
Delegation & Impersonation
- Support/admin impersonation ("login as customer"): verify scope limits (time-boxed, reason captured), immutable audit log entry with actor+target+reason, termination on password change, and no token minting that drops the original actor identity.
- API keys / PATs inheriting the full interactive-user permission set including admin roles; check key scopes exist and are enforced server-side.
- Service-to-service: internal endpoints trusting a forwarded username/email header set by "some gateway" (
X-User-Email) with no signature verification; mTLS absent on internal hops assumed. - OAuth token exchange:
scopedowngrades ignored; refresh tokens carrying original broad scopes after admin revocation.
Where To Look
| Stack | Route table | Guard markers (present = good) | Files/dirs |
|---|---|---|---|
| Express/Fastify/NestJS | app.use/router.stack, Nest @Controller metadata, Fastify printRoutes | requireAuth, passport.authenticate, addHook('preHandler'), @UseGuards(...), @Roles(...) | routes/, controllers/, *.controller.ts, main.ts |
| Django/DRF | urls.py, router.register, django-extensions show_urls | LoginRequiredMixin, PermissionRequiredMixin, permission_classes, DRF get_queryset filter | */urls.py, views.py, viewsets.py, serializers.py |
| Flask/FastAPI | decorators @app.route/@get/@post, OpenAPI spec | before_request, @login_required, `Depends(current_user | require_roles), router-level dependencies=[...]` |
| Spring (Java/Kotlin) | @RequestMapping tree, Actuator /mappings (guarded!) | @PreAuthorize, @Secured, @RolesAllowed, SecurityFilterChain matchers, @EnableMethodSecurity | **/controller/**, SecurityConfig.*, application.yml |
| ASP.NET Core | Controllers + minimal-API Map* chain | [Authorize], [Authorize(Policy=...)], .RequireAuthorization(), FallbackPolicy, [AllowAnonymous] (audit each) | Controllers/, Program.cs, Startup.cs |
| Laravel/Symfony | php artisan route:list, bin/console debug:router | auth: middleware, can: middleware, policies, Gate::authorize, Symfony voters + access_control YAML | routes/web.php, routes/api.php, app/Policies, config/packages/security.yaml |
| Rails | rails routes | before_action :authenticate_user!, Pundit authorize @record / after_action :verify_authorized, CanCanCan load_and_authorize_resource | config/routes.rb, app/controllers/**, app/policies/** |
| Go (net/http/chi/gin) | mux registrations, chi walk | middleware wrappers RequireAuth/RequireRole, gin.Use(...), group-scoped r.Group(func(r){ r.Use(guard)}) | cmd/*/main.go, internal/http/**, middleware/ |
Also inspect: docker-compose.yml/k8s manifests for internal-only ports (context, not proof), OpenAPI/Swagger specs vs implemented routes (spec drift = undocumented live routes), seeders/fixtures revealing role names, frontend JS bundles for hidden admin routes (/static/js/*.js contains "/admin" strings).
Patterns & Signatures
Route inventory (run first; diff counts against guard markers):
rg -n "@(Get|Post|Put|Patch|Delete|Request)Mapping\(" --type java --type kt
rg -n "@app\.(route|get|post|put|patch|delete)|@(get|post|put|delete)\(" --type py
rg -n "\.(get|post|put|patch|delete|all)\(\s*['\"][^'\"]+" -t ts -t js
rg -n "Route::(get|post|put|patch|delete|any|resource|apiResource)" --type php
rg -n "(map|Map)(Get|Post|Put|Delete|Patch)\(\"" --type cs
rg -n "HandleFunc|\.GET\(|\.POST\(|\.DELETE\(|\.PUT\(" --type go
Guard-presence markers:
@(PreAuthorize|Secured|RolesAllowed|PostAuthorize)\(
(LoginRequiredMixin|PermissionRequiredMixin|UserPassesTestMixin)
(@UseGuards|@Roles\(|SetMetadata\(['"](roles|permissions))
Missing-guard smell (Express: handler as 2nd arg, no middleware between):
\.(get|post|put|patch|delete)\(\s*['"][^'"]*['"]\s*,\s*(async\s*)?\(?[A-Za-z_$]
Object-fetch-without-subject heuristics:
objects\.(get|filter)\((pk|id)=
findById\((req|params|pathVariable)[^)]*\)
Model\.objects\.all\(\)
Client-trusted identity signals:
(decoded|payload|claims|token)\.(role|isAdmin|is_admin|admin|permissions)
localStorage\.(getItem\(['"]?(role|is_admin|isAdmin|user)['"]?\))
(req\.body|request\.body|payload)\.(role|isAdmin|is_admin|account_type)
Header/path bypass primitives:
(?i)x-(original-url|rewrite-url|forwarded-for|forwarded-host|http-method-override)
headers\[['"](x-forwarded-for|x-real-ip)['"]\]
(_method|X-HTTP-Method-Override|method_override)
Impersonation & delegation:
(impersonat(e|ion)|acting_as|act_as|as_user|become_user|sudo_mode)
(api[_-]?key|personal_access_token).{0,40}(scopes?|abilities?)
Debug/internal exposure:
(/debug/pprof|/actuator|/_profiler|/__profiler|/telescope|/horizon|DEBUG\s*=\s*True)
Tenant-context-from-input:
(tenant_id|org_id|organization_id)['"]?\s*[:=]\s*(req\.(query|params|body)|request\.(GET|data|json))
Taint Tracing Guidance
Sources (untrusted until proven otherwise): all route params, query strings, bodies, JWT payload claims other than the verified sub, headers (X-*, even Authorization API keys), subdomain/Host, message-queue payloads.
Sinks (authorization-relevant): ORM/document queries (findOne/findById/get/filter/Query.first), raw SQL, file reads/writes/downloads, cache get/put, queue publish/consume, admin RPC calls, email/export generation containing other users' data.
Procedure:
- For each sink fetching a singleton, list its arguments. Flag any lookup helper whose signature lacks a user/tenant/principal parameter — e.g.,
getInvoice(id)vsgetInvoice(id, actor); the omission predicts BOLA upstream of any later check. - Walk backward from sink to source: if the predicate chain from param → WHERE clause does not include the authenticated principal attribute, mark BOLA candidate.
- Track role/permission values: if the value used in the decision originates from a source (JWT body, DB column writable via profile update, Redis session field set from login-time client data) rather than the canonical user store, mark escalation candidate.
- For caches/queues, trace key construction and consumer-side filtering; a tenant tag on write but no filter on read is still vulnerable.
- Sanitizers do NOT exist for authz — a validated integer ID is still a valid other-tenant ID. Never treat validation as authorization.
Framework shortcuts: DRF get_queryset() overrides (absence = base manager queryset = leak); Rails ApplicationRecord default scopes (none by default); SQLAlchemy session.query(Model).get(id) ignores any Python-level tenant notion unless filters added; JPA EntityManager.find(Cls, id) never tenant-filters without Hibernate @TenantId/@Filter.
Exploitation & Reproduction
Two-Account BOLA Test (canonical sequence)
- Register victim and attacker accounts:
curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \ -d '{"email":"victim@example.com","password":"V1ctimPass!"}' curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \ -d '{"email":"attacker@example.com","password":"Att4ckerPass!"}' - Authenticate both; store tokens:
VICTIM=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \ -d '{"email":"victim@example.com","password":"V1ctimPass!"}' | jq -r .token) ATTACKER=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \ -d '{"email":"attacker@example.com","password":"Att4ckerPass!"}' | jq -r .token) - Victim creates an object; capture its ID:
curl -s -X POST https://target/api/invoices -H "Authorization: Bearer $VICTIM" \ -H 'Content-Type: application/json' -d '{"amount":100,"memo":"secret-payroll"}' # -> {"id":1042,...} (sequential => enumerate 1030..1050 next) - Attacker requests the victim's object:
curl -i https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" - Interpret (this triage matters):
Result Meaning 200+ victim's fields ("secret-payroll")Confirmed BOLA (CWE-639) 200+ empty/redacted body but different timing than random IDsExistence oracle; partial protection 403Function reachable, object denied — check OTHER verbs ( curl -i -X PUT/PATCH/DELETE) and nested paths before clearing404for foreign IDs,200for ownCorrect anti-enumeration masking — verify owner still receives full record - Repeat for write paths (swap IDs on PATCH/DELETE), collections (
GET /api/invoiceswithout?mine=1), exports (GET /api/invoices/export.csv), and nested forms (GET /org/A/invoices/1042where 1042 belongs to org B). - Static-only confirmation (no test accounts available): locate the handler for the route, show the fetch statement has no ownership predicate, and show no policy/middleware wraps the route — cite file:line for both halves.
BFLA Admin-Endpoint Test
- As ATTACKER (normal user), call an administrative mutation:
curl -i -X POST https://target/api/users -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"email":"pwn@example.com","password":"Pwn3dPass!","role":"admin"}' - Outcomes:
201/200= confirmed BFLA (+ possible privilege self-grant);403= guarded;401= route also misses authentication (worse). - Role-injection variant at registration/update:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://target/api/register \ -H 'Content-Type: application/json' \ -d '{"email":"esc@example.com","password":"Esc4late!","role":"admin","isAdmin":true}' # then re-login as esc@example.com and retry step 1 - Method-swap variant against GET-guarded routes:
curl -i -X DELETE https://target/admin/users/42 -H "Authorization: Bearer $ATTACKER".
Path/Header/Verb Bypass Tests
# Path confusion (use --path-as-is so curl does not normalize)
for p in '/admin/users' '/admin//users' '/admin/./users' '/admin%2fusers' '/adm%69n/users' \
'/admin;/users' '/admin/users/' '/ADMIN/users' '/api/v1/admin/stats'; do
curl -s -o /dev/null -w "%{http_code} $p\n" --path-as-is "https://target$p" -H "Authorization: Bearer $ATTACKER"
done
# Double-encoding round trip: %252f decodes once to %2f, again to '/'
# Proxy-header injection
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/public/health' -H 'X-Original-URL: /admin/users'
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/admin/stats' -H 'X-Forwarded-For: 10.0.0.1'
# Verb tampering incl. overrides
for m in HEAD OPTIONS TRACE PATCH PUT; do curl -s -o /dev/null -w "%{http_code} $m\n" -X $m https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER"; done
curl -s -X POST https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" \
-H 'X-HTTP-Method-Override: DELETE' -d '_method=DELETE'
Expected observable outcome: any status other than uniform 403/404 across all variants (and identical behavior with a valid-but-unprivileged token) demonstrates a guard-bypass primitive; record which exact byte-sequence bypassed it.
Remediation
Centralize the Policy Layer
Move every decision into ONE choke point per stack; handlers may refine but never be the sole guard:
| Stack | Choke point |
|---|---|
| Express/Fastify | Router-level middleware registered before branch handlers (guardRouter.use(requireAuth, attachPolicy)) |
| NestJS | Global APP_GUARD JwtAuthGuard + RolesGuard; per-handler @Roles() metadata |
| FastAPI | dependencies=[Depends(require_auth)] on the router; policy dependency per operation |
| Django | Mixins on every CBV + DRF DEFAULT_PERMISSION_CLASSES + custom get_queryset base class |
| Flask | before_request blueprint hook + decorator on every view |
| Spring | SecurityFilterChain path rules + @EnableMethodSecurity + @PreAuthorize default-deny meta-annotation |
| ASP.NET Core | FallbackPolicy = RequireAuthenticatedUser globally; [Authorize(Policy)] per area |
| Laravel | Route middleware groups (auth:sanctum) + Policies auto-discovery; $gate->deny default |
| Symfony | access_control YAML + voters invoked via isGranted in a base controller/subscriber |
| Rails | before_action :authenticate_user! in ApplicationController + Pundit verify_authorized after_action raising on skipped checks |
| Go | Single func Authz(next http.Handler) http.Handler wrapper composed onto every non-public route group |
Before / After Recipes
Express:
// VULNERABLE
router.get('/invoices/:id', async (req, res) => {
const inv = await Invoice.findByPk(req.params.id);
res.json(inv);
});
// FIXED
const { requireAuth } = require('./middleware/auth');
const { authorize } = require('./policy'); // centralized decision engine
const { scopeToTenant } = require('./middleware/tenant');
router.get('/invoices/:id', requireAuth, authorize('invoice:read'),
scopeToTenant, async (req, res) => {
const inv = await Invoice.findOne({ // predicate lives IN the query
where: { id: req.params.id, tenantId: req.user.tenantId }
});
if (!inv) return res.sendStatus(404); // uniform mask, no existence oracle
res.json(inv);
});
Django/DRF:
# VULNERABLE
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
# FIXED
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
permission_classes = [IsAuthenticated, TenantMemberRequired]
def get_queryset(self): # object axis enforced centrally
return Invoice.objects.filter(
tenant_id=self.request.user.tenant_id,
)
# FIXED (classic Django CBV equivalent)
class InvoiceDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
model = Invoice
permission_required = 'invoices.view_invoice'
def get_queryset(self):
return Invoice.objects.filter(tenant=self.request.user.tenant)
Spring:
// VULNERABLE
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id) {
return invoices.findById(id).map(mapper::toDto)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
// FIXED
@PreAuthorize("hasAuthority('invoice:read')")
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id, @AuthenticationPrincipal AppUser user) {
return invoices.findByIdAndTenantId(id, user.getTenantId())
.map(mapper::toDto)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
Laravel:
// VULNERABLE
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']);
// FIXED
Route::middleware(['auth:sanctum'])
->get('/invoices/{invoice}', [InvoiceController::class, 'show'])
->middleware('can:view,invoice'); // policy = single decision point
// app/Policies/InvoicePolicy.php (registered automatically by name convention)
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id;
}
}
Rails (Pundit):
# VULNERABLE
def show
@invoice = Invoice.find(params[:id])
end
# FIXED
def show
@invoice = Invoice.find(params[:id])
authorize @invoice # raises unless policy permits
end
# with_scope in policy keeps index endpoints tenant-safe:
class ApplicationPolicy
class Scope
def resolve
scope.where(tenant_id: user.tenant_id)
end
end
end
Deny-by-Default Routing
// Express: public surface is an EXPLICIT allowlist; everything else falls through the guard wall
app.use(['/health', '/live', '/ready', '/login', '/register'], publicRouter);
app.use(requireAuth); // wall: nothing below resolves without identity
app.use('/admin', requireRole('admin')); // vertical wall for whole subtree
app.use('/api', apiRouter); // apiRouter assumes authenticated ctx
# Spring Security: authenticate everything, permit only listed public paths
security:
config:
http:
authorizeHttpRequests:
- "/health"
- "/login"
others: "authenticated"
admin-prefix: "/admin/**"
admin-role: "ROLE_ADMIN"
method-security: true
Equivalent defaults: ASP.NET FallbackPolicy (see below), Symfony last-resort - { path: ^/, roles: ROLE_USER }, Laravel auth on the api group, Rails authenticate in ApplicationController with skip_before_action audited per use.
// ASP.NET Core: fail closed
builder.Services.AddAuthorization(o =>
{
o.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build();
});
// minimal APIs: app.MapGet("/x", h).RequireAuthorization("TenantMember");
Tenant Scoping & Row-Level Security
- Enforce tenancy in ONE layer beneath all queries: repository base classes, DRF
get_queryset, EF Core global query filters (HasQueryFilter(i => i.TenantId == _tenant.Id)), Hibernate@Filter/@TenantId, SQLAlchemy events, or Postgres RLS as backstop:
-- Postgres row-level security as defense-in-depth under app-level checks
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- per request/connection: SET LOCAL app.tenant_id = '<uuid-from-session>';
- Name cache keys and queue headers with tenant scope (
t:{tenant}:invoice:{id}); re-validate tenant on consume. - Replace sequential integers with opaque UUIDv4 for external references (mitigation, not fix).
- Impersonation: dedicated short-TTL token type carrying
act(actor) + target, reason string persisted to append-only audit table, auto-expiry on target password change, admin-only grant of the impersonation permission itself.
Verification & Validation
Automated Tests (GIVEN/WHEN/THEN)
Scenario: Owner retains access (negative test against over-blocking)
GIVEN user A authenticated and owning invoice 1042
WHEN A requests GET /api/invoices/1042
THEN status is 200 AND body contains A's memo value
Scenario: Non-owner denied on read
GIVEN users A and B on distinct tenants
WHEN B requests GET /api/invoices/1042
THEN status is 403 or 404 AND body contains no A-owned field values
Scenario: Non-owner denied on write
WHEN B sends PATCH /api/invoices/1042 {"amount": 1}
THEN status is 403/404 AND a follow-up GET by A shows unchanged amount
Scenario: Role matrix cell denial
GIVEN role "viewer" exists with read-only rights
WHEN viewer calls DELETE /api/invoices/1042
THEN status is 403 regardless of ownership
Scenario: Unauthenticated denial
WHEN request omits credentials entirely
THEN status is 401 for every non-public route
Run the matrix for EVERY route × EVERY role × {own, foreign, nonexistent} object ID; assert status codes, not just absence of error text.
Route-Coverage Test (deny-by-default assertion)
// pseudocode — port enumerator per framework (Express stack walk, rails routes, artisan route:list)
const PUBLIC_ROUTES = new Set(['/health', '/live', '/ready', '/login', '/register', '/docs', '/openapi.json']);
test('every non-public route declares >=1 authz guard; no public mutations', () => {
for (const r of enumerateRoutes(app)) {
const isPublic = PUBLIC_ROUTES.has(r.path);
const guards = r.middleware.filter(isAuthzGuard); // requireAuth/authorize/roles...
expect(guards.length >= 1 || isPublic).toBe(true); // deny-by-default
if (isPublic) expect(r.methods.some(m => ['POST','PUT','PATCH','DELETE'].includes(m))).toBe(false);
}
});
CI companion: snapshot-test the route table (rails routes, artisan route:list, Nest route dump) and fail review when count grows without a paired guard change.
Manual Re-test Checklist
- Re-run two-account sequence from Exploitation section; confirm
403/404and owner200. - Re-run all path/header/verb bypass loops; confirm uniform denial.
- Confirm admin can STILL operate (fixes frequently over-block; test each role-matrix cell positively).
- Confirm export/download and GraphQL nested-field paths now enforce ownership.
- Confirm impersonation writes audit rows and expires correctly.
- Confirm cache keys are tenant-prefixed and stale pre-fix entries purged (flush affected namespaces).
Post-Fix Greps
rg -n "skip_authorization|SkipAuthorization" app/
rg -n "objects\.(get|filter)\((pk|id)=" --type py
rg -n "findById\(" --type java --type kt | rg -v "AndTenant|ForTenant|Scoped"
rg -ni "x-(original-url|rewrite-url)" .
rg -n "x-forwarded-for" -i . | rg -v "trust proxy|trustedProxies|rate.?limit"
rg -n "@(Get|Post|Put|Delete|Patch)Mapping\(" --type java --type kt | wc -l # compare vs @PreAuthorize count
Severity Assessment
| Finding archetype | Example | Baseline CVSS v3.1 vector | Score band |
|---|---|---|---|
| Unauthenticated admin action | POST /api/users reachable pre-auth | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H | Critical (9.8) |
| Vertical escalation, authenticated write | normal user creates/disables users | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H | High (8.8) |
| Cross-tenant bulk read | listing returns all tenants' records (scope-changing) | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N | High (7.7) |
| Horizontal write, single object | attacker edits another tenant's invoice | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N | Medium–High (6.5) |
| Single-record cross-tenant read | one foreign record via guessed ID, high-sensitivity data | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N | Medium (6.5) |
| Existence/timing oracle only | 200-empty vs 404 differentiation | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N | Low–Medium (3.1–4.3) |
Rubric modifiers: enumerable sequential IDs or absent rate limits push one band up; secrets/PHI/financial fields push up; compensating network ACLs on genuinely internal-only services push down but never to informational while the code defect persists. Admin takeover ≥9.0 always Critical; cross-tenant read defaults High even for one record when data is personal or confidential.
Primary CWE mapping: missing guard entirely → CWE-862; guard present but wrong → CWE-863; user-controlled key to another's object → CWE-639; umbrella → CWE-285; shared cache/queue granularity → CWE-1220.
Common False Positives
- Gateway/sidecar enforcement: bare handlers behind an authorizing API gateway, service mesh
AuthorizationPolicy, or mTLS-spiffe mesh. Verify the enforcement point exists and covers the route before dismissing; document it in the report. - Intentionally public endpoints: pricing catalogs, status pages, docs — confirm product intent and absence of mutation methods.
- UUID capability references: unguessable IDs reduce practical exploitability, but the missing ownership predicate remains a real defect — report as lower-severity hardening gap, not a false positive of the pattern itself.
- Deliberate 404-masking: uniform
404for foreign objects with200for owners is correct design, not a leak — confirm response timing/body are indistinguishable for nonexistent vs foreign IDs. - Dev/test-only routes compiled out of production builds (verify build flags/env gates actually exclude them, e.g.,
if (process.env.NODE_ENV !== 'production')). - Internal tools behind VPN + mTLS where identity is established at network layer — residual risk note only if the same service also binds a public interface.
- Read endpoints already filtered at the serializer to public fields — confirm no private field slips through relations/nested serializers.
References
- CWE-284: Improper Access Control
- CWE-285: Improper Authorization
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-862: Missing Authorization
- CWE-863: Incorrect Authorization
- CWE-1220: Insufficient Resource Granularity
- OWASP Cheat Sheet Series: Authorization Cheat Sheet
- OWASP Cheat Sheet Series: Access Control Cheat Sheet
- OWASP Cheat Sheet Series: Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP API Security Top 10 (2023): API1 Broken Object Level Authorization
- OWASP API Security Top 10 (2023): API5 Broken Function Level Authorization
- OWASP Top 10 (2021): A01 Broken Access Control
- OWASP ASVS 4.0.x — Section V4 Access Control
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/as-foss/aegis-skills/aegis-authz-access-control">View aegis-authz-access-control on skillZs</a>