Skip to content
QSWEQB
hardDesignConceptMidSeniorStaffLead

Customers want per-project roles rather than one global role. How do you model that, and where in the request does the check happen?

Model permission as a relationship between a subject, a role and a specific resource, not as a role stamped on the user, and push the check down into the data access predicate so a list query and a single-record fetch are authorised by the same rule.

6 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate separate "which action" from "on which object", since checking only the first is how every IDOR bug happens
  • Whether the list endpoint is recognised as the hard case, because per-row policy evaluation cannot express a filtered query
  • That OAuth scopes are not confused with user permissions
  • Can they defend a deny-by-default posture including what it costs in developer friction
  • Whether tenant isolation is enforced somewhere a forgotten WHERE clause cannot bypass

Answer

Two questions, and only one of them usually gets asked

Every authorisation decision answers two independent questions: may this subject perform this action, and may they perform it on this object. A global-role system collapses them, because when a role means the same thing everywhere, knowing the role settles both. The moment a customer wants Ana to administer project A and merely read project B, the collapse stops working, and code written against the collapsed model fails in a specific and predictable way.

# The bug, in the shape it almost always appears. The role check passes -
# Ana genuinely is a member - and nothing establishes that this invoice
# belongs to a project she is a member OF.
@require_role("member")
def get_invoice(invoice_id, user):
    return db.one("SELECT * FROM invoice WHERE id = %s", (invoice_id,))

# Authorisation folded into the predicate, so there is no window in which
# the row is loaded before the decision is made, and no second code path
# that can forget the check.
def get_invoice(invoice_id, user):
    return db.one(
        """SELECT i.*
             FROM invoice i
             JOIN project_member m ON m.project_id = i.project_id
            WHERE i.id = %s
              AND m.user_id = %s
              AND m.role IN ('viewer', 'editor', 'admin')""",
        (invoice_id, user.id),
    )

That first version is the whole of the OWASP broken-access-control family in six lines. It is not exotic and it is not caused by ignorance of security; it is caused by a permission model that had nowhere to put the object.

What the model has to look like

The unit is a tuple, not a column. (subject, role, resource) — Ana is admin on project:17, Ben is viewer on project:17, the deployment robot is writer on project:17. A role is then just a named bundle of permissions, resolved through a second table mapping role to permission, so that adding a permission to editor is data rather than a deploy.

CREATE TABLE project_member (
  project_id bigint NOT NULL REFERENCES project,
  user_id    bigint NOT NULL REFERENCES app_user,
  role       text   NOT NULL REFERENCES role_definition,
  PRIMARY KEY (project_id, user_id)     -- one role per user per project
);

Three refinements come up as soon as the model is real. Roles usually need to be inheritable down a hierarchy, because "admin of the organisation" must imply admin of every project inside it, and you either materialise those implied tuples or resolve them at query time with a recursive walk. Group membership is the same problem again with a different subject type, and it is worth making the subject column polymorphic early rather than discovering that a team needs access. And some rules genuinely depend on attributes rather than relationships — a payment above a threshold needs a second approver, an invoice becomes read-only once approved — which is where attribute-based rules earn their place alongside the relationship tuples rather than replacing them.

The vocabulary is worth using precisely, because interviewers listen for it. Role-based access control keys off what you are; attribute-based access control evaluates a rule over properties of the subject, object and environment; relationship-based access control, the model behind Google's Zanzibar paper and the systems that copied it, keys off a graph of tuples exactly like the one above. Real products end up with the third as the backbone and the second layered on for conditional rules.

The list endpoint is where architectures break

A policy engine with a clean can(subject, action, object) interface is a pleasure to use for GET /invoices/42 and an active problem for GET /invoices. You cannot fetch ten thousand invoices and ask the engine about each one, and you cannot fetch page one and then filter it, because filtering after the limit gives the caller a short page and eventually an empty one that is not the last page.

So the requirement is that the policy must be expressible as a predicate you can push into the query — a set of project identifiers, a join, or a subquery the database can plan. Say this out loud, because it constrains the whole design: any authorisation rule that cannot be reduced to a filter will not survive contact with a paginated list. Systems built around external policy engines solve it by having the engine return the set of resource identifiers the subject can reach, or by having it compile the policy into a filter, and both of those are features you check for before adopting one.

Enforcement point, and the layer that catches the mistake you will make

Put the decision in one place per operation, and make that place the layer that owns data access rather than the HTTP handler. Handlers multiply — an HTTP route, a background job, a GraphQL resolver, an admin script — and a check that lives in the handler is a check that exists once per handler and is therefore missing somewhere.

Then add a layer beneath it that does not depend on anyone remembering. For a multi-tenant schema in PostgreSQL, row-level security is the tool: a policy on each tenant-scoped table restricting visible rows to the current tenant, with the tenant set per request via SET LOCAL app.tenant_id inside the transaction. SET LOCAL is the important detail, because a connection pool hands the same physical connection to the next request and a session-scoped SET leaks across tenants — which is a worse bug than the one you were preventing. Note also that row-level security is bypassed by the table owner and by superusers unless you force it, so the application role must not own its own tables.

Deny by default is the other structural choice. A new table with no policy, a new endpoint with no rule, a new action with no mapping should all be inaccessible rather than open. It costs friction on every feature, and it converts the failure mode from "silently exposed" to "visibly broken in staging", which is the trade you want.

Scopes and permissions answer to different parties

A recurring conflation is treating an OAuth scope as a permission. A scope bounds what a client application is allowed to ask for on the user's behalf; it never grants the user anything. A token carrying invoices:write for a user who is a viewer on the project must still be refused, and a token carrying no scopes for an admin must also be refused. Both checks apply, and the effective permission is the intersection. Embedding the full permission set into the token is a related mistake: it makes revocation wait for expiry, and once a user belongs to hundreds of projects it does not fit in a header anyway.

Finally, decide deliberately between 403 and 404 for a resource that exists but is not yours. 403 confirms existence, which for identifiers a competitor can enumerate is itself a leak; 404 hides it at the cost of a confusing support conversation. Pick per resource type, write it down, and be consistent, because inconsistency is itself the oracle an attacker uses.

Permission is a fact about a subject and a specific object, so it belongs in the query that fetches the object. Any design where the single-record path and the list path derive their authorisation from different logic will diverge, and the direction it diverges in is always the unsafe one.

Likely follow-ups

  • A user is removed from a project while holding a valid access token. When do they stop seeing its data?
  • How would you let a support engineer act as a customer without handing them the customer's credentials?
  • Where do you put the decision when a permission depends on the record's own state, such as an invoice being locked after approval?
  • How do you test authorisation, given that the bug is always an absence rather than a presence?

Related questions

Further reading

authorisationrbacabacmulti-tenancyrow-level-security