Authorization: Ownership, Relationships & Roles
Learning Objectives
- You can express permissions using ownership and resource relationships.
- You can enforce a policy where a resource is read or changed, not only in UI controls.
- You can verify that a resource in a nested URL belongs to its stated parent.
- You can explain how role-based access control groups permissions.
Our notes requests now carry a cookie that lets the server resolve a session to an account. Suppose another signed-in account sends an edit for the same note. Its valid session does not give it permission to change someone else’s note. We need a permission check for this account, this operation and the current note.
Authentication establishes the request’s actor: the account it represents. Authorization decides whether that actor may perform this operation on this resource. Keep credential verification and session resolution separate from resource policies: the authentication code supplies the trusted actor, and the resource operation loads the current record and checks the relationship before changing it. A named policy function is enough to express a small relationship rule.
Ownership is one such relationship. To see how the same idea extends beyond a single owner, let’s use a helpdesk ticket with a reporter and an assigned agent. Both may read it, but only the assigned agent may close it. An unrelated authenticated employee may do neither.
Ownership, assignment, conversation participation and review responsibility are resource relationships. Ticket assignment applies to one ticket; allowing every agent to close every ticket would be a different policy.
Write the ticket permission rules
We can express the read and close decisions as two small functions. Follow which relationship each one checks:
type Actor = { id: number };type Ticket = { reporterId: number; agentId: number | null; status: "open" | "closed";};
export const canReadTicket = (actor: Actor | null, ticket: Ticket): boolean => { return ( actor !== null && (actor.id === ticket.reporterId || actor.id === ticket.agentId) );};
export const canCloseTicket = (actor: Actor | null, ticket: Ticket): boolean => { return ( actor !== null && actor.id === ticket.agentId && ticket.status === "open" );};The service supplies the current ticket and either a trusted actor or null for an anonymous request. These policy functions neither identify the current user nor query a database.
A null ticket.agentId means the ticket is unassigned; do not substitute a default user ID.
Figure 1 follows the close-ticket rule. The actor must be assigned to this ticket, and the ticket must still be open. Either failed check denies the change.
Check the permission matrix
Before connecting these decisions to a route, predict the results for an open ticket reported by user 1 and assigned to user 2. Compare your predictions with the matrix:
| Actor | May read? | May close? |
|---|---|---|
| Reporter, user 1 | Yes | No |
| Assigned agent, user 2 | Yes | Yes |
| Unrelated user 3 | No | No |
Anonymous, null | No | No |
A valid identity does not grant access: unrelated user 3 cannot read the ticket. An unassigned ticket cannot be closed, and a closed ticket fails the state check even for its assigned agent.
Role-based access control
Role-based access control (RBAC) groups permissions into roles and assigns users one or more roles. RBAC is widely used in application and organizational access management. So far, our ticket rules use relationships to one resource. An application can also group permissions that several accounts share. For example, an editorial application might give its Editor role permission to edit and publish articles.
Figure 2 maps users to a role and the role to permissions. Changing those permissions affects the role’s assigned users.
Role assignments must come from trusted current server state. Accepting role: "editor" from a request body would let callers choose their own authority.
The editorial application can also require assignment to this article and readiness for publication. Role permissions do not replace those relationship and state checks.
The marketplace project uses resource relationships for its permission checks.
Check permissions in the service
Return to the ticket’s Close button. We can hide it from an unrelated account, just as we can hide a note’s Edit button from another account. A caller can still send POST /api/tickets/:id/close directly, so the service must make the permission decision too.
The service loads the current ticket, checks the policy and changes its state. If another operation can change the assignment or ticket state, coordinate the check and mutation using a row lock or conditional update.
authenticate request-> begin protected operation-> load current resource-> check actor/resource relationship-> perform allowed transition-> commitIf the service releases a lock after checking assignment, another operation may reassign the ticket before the write. An update that does not recheck assignment could then close a ticket the actor is no longer allowed to close.
The synchronous operation uses the same types and receives the session-derived actor (or null) from the service:
export const closeTicket = (actor: Actor | null, ticket: Ticket): Ticket => { if (!canCloseTicket(actor, ticket)) { throw new Error("FORBIDDEN"); } return { ...ticket, status: "closed" };};This pure operation returns a new value and leaves the original ticket unchanged on denial. It has no database write or asynchronous gap. Persistent operations need the coordination described above.
Find the missing ticket rule
0 / 5 points
The policy permits only the ticket reporter to delete a ticket. The route accepts any signed-in actor and deletes a ticket. The UI displays Delete only to the reporter. What should change?
A nested URL does not prove membership
Now place the ticket under a project at /api/projects/:projectId/tickets/:ticketId. The URL names a relationship, but a caller can choose both IDs. Verify that the ticket belongs to the project. Looking up only ticketId can bypass a project-specific permission check.
Query by both ticketId and projectId, or compare the ticket’s stored project ID with projectId before proceeding.
Choose authorization error responses
The HTTP boundary chooses a consistent status policy: 401 for missing authentication, 403 for a known forbidden operation, or 404 for both absent and inaccessible resources when the contract conceals their existence. The pure example’s FORBIDDEN error does not itself choose an HTTP status. Do not reveal extra resource fields to explain a denial.
Grant only the permissions an actor needs (least privilege), and reject an operation unless a rule explicitly allows it (deny by default). The OWASP Authorization Cheat Sheet also emphasizes checking permissions for each request.
Test the matrix and the route
We have a policy matrix and a place to enforce it. To check that the route uses the policy, send a request an unrelated actor could actually make. Typed routes and fields cannot establish the caller’s relationship to the current server record.
To isolate an authorization rule in a test, use an authenticated actor whose relationship forbids the operation. Check both the denial and the unchanged resource.
Use the actor/resource matrix for the pure operation and HTTP integration: anonymous, reporter, assigned agent, unrelated actor, unassigned ticket, and closed ticket. Chapter 5.8 shows a permitted/denied request pair.
Inject trusted test actors through the test process, never through a public identity header. Real-session integration tests also exercise the authentication middleware.
Use the same reasoning in the document exercise below. An author and assigned reviewers have different relationships to a document, just as a reporter and assigned agent do to a ticket. The exercise’s withDocument callback receives the current document or null if it is missing. Check permission before changing the title inside that callback; the supplied repository owns coordination.
Protect a document through its relationships
0 / 30 points
Files in the editor
The editor below contains one file. Edit src/services/document-policy.ts.
The types and repository interface are in this file. The grader supplies the
repository and its withDocument operation as an argument.
Task
Complete canRead(actor, document), canReview(actor, document), and
canEdit(actor, document), each returning a Boolean, and renameDocument(repository,actor,id,title)
in src/services/document-policy.ts. An actor is {id:number} or null.
A document is {id,authorId,reviewerIds:number[],status:"draft"|"published",title}.
An author and assigned reviewers may read the document in either
state. Only assigned reviewers may review it. Only its author may rename a draft;
publication closes draft editing for everyone. Anonymous and unrelated actors have
no permissions. One actor may be both author and reviewer.
withDocument supplies the current document to its callback. Its real
implementation must protect the read/check/write operation together. Put the
permission decision inside that callback, before changing the document. Return
{kind:"unauthenticated"} for anonymous, {kind:"not_found"} for missing,
{kind:"forbidden"} for forbidden, or {kind:"renamed"} after changing only title.
The HTTP adapter maps these outcomes to statuses; this service does not know Hono
or HTTP status codes. Check in that order. IDs and titles are already validated at this boundary.
Denied calls leave the document unchanged. Do not cache a previous actor’s decision.
Independent checks award 10 points for membership, 5 for lifecycle, 5 for a
successful mutation, and 10 for rejection with unchanged state. The fake
repository establishes the policy, not actual database locking. The starter’s
withDocument(id, callback) returns a Promise of the callback’s result; the
callback receives the current document or null.
Suggested progression
First build the Boolean relationship/state matrix. Then use those decisions inside withDocument, and inspect the document after denied mutations.
Check and submit
Choose Submit solution below the editor to run the grading checks and read the feedback. The editor submits the file above. Keep file paths and exported names as specified.
The note owner, ticket agent and document author each gain permission through a relationship stored on the server. Establishing that permission still leaves another question: what happens when stored or submitted text becomes part of a query, response or page? The next chapter follows data through those contexts.
Check Your Understanding
- Why is authentication necessary but insufficient for authorization?
- Where must a mutation policy be enforced?
- Why does a nested path not establish a database relationship?
- How can an authorization check become stale before a write?
- How does RBAC connect users to permissions, and why might a resource relationship still be checked?