{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"authorisation-beyond-ownership","title":"Authorisation beyond ownership","url":"https://addaly.com/learn/building-apps-with-ai/authorisation-beyond-ownership","position":69,"of":87,"minutes":9,"answer":"Most rows are seen by more than one kind of person, so the check is a single named function that answers 'may this user do this action to this row'.","keyIdea":"Most rows are seen by more than one kind of person, so the check is a single named function that answers 'may this user do this action to this row', called on every route and tested with three accounts rather than two."},"body":{"format":"markdown","text":"## The check that is not enough\n\nThe earlier security lesson gave you the one-line fix: `AND user_id = $2`, with the id from the session. That is the right answer for a note only its author reads. Most real rows are not like that.\n\nA booking belongs to the customer who made it and to the business that will serve them. An invoice is visible to the client, to the accountant the client added, and to the firm that issued it. A document in a shared folder is visible to everyone in the folder. In each case the ownership check is either wrong (it blocks people who should see the row) or, once the agent has been asked to make the page work, deleted, so that everybody sees everything.\n\nThe pattern that survives is a named function.\n\n## One function, asked everywhere\n\n```typescript\n// lib/can.ts\nexport async function can(user: User, action: \"read\" | \"cancel\" | \"edit\", booking: Booking) {\n  if (booking.userId === user.id) return action !== \"edit\";           // the customer\n  const member = await db.query.memberships.findFirst({\n    where: and(eq(memberships.userId, user.id), eq(memberships.businessId, booking.businessId)),\n  });\n  if (member) return true;                                             // staff of the business\n  return false;\n}\n```\n\nEvery route that touches a booking loads it, calls `can`, and returns 404 if the answer is no. The rules live in one file, the tests live beside it, and when the rule changes (accountants can now read, staff cannot cancel) it changes in one place rather than in the eleven routes where an agent scattered slightly different conditions.\n\nMembership is a table: `memberships(user_id, business_id, role)`. Not a column on the business, not a list of ids in a JSON field, a table with a foreign key each way. It is how \"the business\" becomes \"the people who work at the business\", and it is how a person leaves without their access lingering.\n\n## 404, not 403\n\nWhen the answer is no, return the same response you would for a row that does not exist. A 403 tells the caller that the id is real and belongs to somebody; a 404 tells them nothing. The difference matters for enumerable ids from the data block, where a script trying ids in sequence learns from every 403 exactly which ones are worth attacking later. Log the refusal on your side, so you can see the script; give it nothing on the wire.\n\n## Three accounts, not two\n\nThe earlier test was two accounts: log in as B and try to reach A's things. With shared resources, the test needs three.\n\n- **A**, the customer who made the booking.\n- **B**, a member of the business.\n- **C**, a member of a different business.\n\nA and B should see it. C should not. Then the actions: A can cancel but not edit; B can edit; C can do nothing. Write this as a script that runs against the preview deployment, one request per cell, checking the status code, and run it before every release. It is thirty lines and it is the test that catches the agent making the page work by removing the check.\n\n## Lists are routes too\n\nThe single-row check is where people look. The list is where the leak usually is. `GET /api/bookings` returning every booking, with the filtering done on the page, is the same hole with a wider mouth; the Network tab shows the whole table. Every list query starts from the user: their bookings, or the bookings of businesses they are a member of, joined through the membership table. Never the full table filtered afterwards in JavaScript.\n\n## Actions the model wires wrongly\n\nThree requests, each reasonable, each producing a hole when built quickly.\n\n\"Let staff manage bookings.\" Built as a `role` column on users, and every check becomes `if (user.role === \"staff\")`, which makes staff at one business able to manage bookings at all of them. The role belongs on the membership, scoped to the business.\n\n\"Add an admin who can see everything.\" Built as `isAdmin` on the users table, set from the profile form, which is the mass-assignment hole in the lesson after next. Admin belongs in an environment allowlist, not in a column a form can reach.\n\n\"Let users share a document by link.\" Built as a public route keyed by the document id, the guessable id again. A share link is a separate random token in a `shares` table, revocable, with its own expiry.\n\n## The question for the agent\n\nNot \"is this secure\", which produces reassurance. For each route: \"which line decides whether this user may perform this action on this row, and what does it return when they may not\". If the answer for any route is a line that compares only `user_id`, ask whether anyone else should see that row. If the answer is that the front end hides the button, there is no check."},"check":{"question":"A booking is visible to the customer who made it and to the business it was made with. The route checks booking.userId === session.userId. The business owner opens the booking and gets 404. The agent proposes removing the check so the page works. What is the right change?","options":[{"text":"Remove the check for the business's routes only, since business owners are trusted users who have signed an agreement with the platform and are contractually accountable for how they use it.","correct":false,"tempting":"Trust is not a check; any signed-in business could then read every booking on the platform, not only its own."},{"text":"Keep the check and give the business owner a separate admin page that lists all bookings.","correct":false,"tempting":"An admin page that lists all bookings shows every business every booking; the scope problem has moved, not gone."},{"text":"Add the business owner's id to the booking row as a second owner column and check either one.","correct":false,"tempting":"Works for one owner and breaks when the business has staff; membership belongs in a table, not in columns added per person."},{"text":"Replace the ownership test with a can() function that allows the customer, or a member of the business the booking belongs to, and returns 404 otherwise.","correct":true}]},"prev":{"slug":"security-holes-ai-code-has-by-default","title":"The security holes AI code has by default","url":"https://addaly.com/learn/building-apps-with-ai/security-holes-ai-code-has-by-default"},"next":{"slug":"the-built-bundle-and-the-history","title":"Secrets in the built bundle, and in the history","url":"https://addaly.com/learn/building-apps-with-ai/the-built-bundle-and-the-history"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Authorisation beyond ownership', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/authorisation-beyond-ownership (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Authorisation beyond ownership","courseTitle":"Building Apps with AI","module":{"index":8,"slug":"the-holes-ai-code-has-by-default","title":"The security holes AI code has by default"},"answer":null,"keyIdea":"Most rows are seen by more than one kind of person, so the check is a single named function that answers ‘may this user do this action to this row’, called on every route and tested with three accounts rather than two.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-check-that-is-not-enough","runs":[{"text":"The check that is not enough"}]},{"t":"p","runs":[{"text":"The earlier security lesson gave you the one-line fix: "},{"text":"AND user_id = $2","code":true},{"text":", with the id from the session. That is the right answer for a note only its author reads. Most real rows are not like that."}]},{"t":"p","runs":[{"text":"A booking belongs to the customer who made it and to the business that will serve them. An invoice is visible to the client, to the accountant the client added, and to the firm that issued it. A document in a shared folder is visible to everyone in the folder. In each case the ownership check is either wrong (it blocks people who should see the row) or, once the agent has been asked to make the page work, deleted, so that everybody sees everything."}]},{"t":"p","runs":[{"text":"The pattern that survives is a named function."}]},{"t":"h2","id":"one-function-asked-everywhere","runs":[{"text":"One function, asked everywhere"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"// lib/can.ts\nexport async function can(user: User, action: \"read\" | \"cancel\" | \"edit\", booking: Booking) {\n  if (booking.userId === user.id) return action !== \"edit\";           // the customer\n  const member = await db.query.memberships.findFirst({\n    where: and(eq(memberships.userId, user.id), eq(memberships.businessId, booking.businessId)),\n  });\n  if (member) return true;                                             // staff of the business\n  return false;\n}"},{"t":"p","runs":[{"text":"Every route that touches a booking loads it, calls "},{"text":"can","code":true},{"text":", and returns 404 if the answer is no. The rules live in one file, the tests live beside it, and when the rule changes (accountants can now read, staff cannot cancel) it changes in one place rather than in the eleven routes where an agent scattered slightly different conditions."}]},{"t":"p","runs":[{"text":"Membership is a table: "},{"text":"memberships(user_id, business_id, role)","code":true},{"text":". Not a column on the business, not a list of ids in a JSON field, a table with a foreign key each way. It is how “the business” becomes “the people who work at the business”, and it is how a person leaves without their access lingering."}]},{"t":"h2","id":"404-not-403","runs":[{"text":"404, not 403"}]},{"t":"p","runs":[{"text":"When the answer is no, return the same response you would for a row that does not exist. A 403 tells the caller that the id is real and belongs to somebody; a 404 tells them nothing. The difference matters for enumerable ids from the data block, where a script trying ids in sequence learns from every 403 exactly which ones are worth attacking later. Log the refusal on your side, so you can see the script; give it nothing on the wire."}]},{"t":"h2","id":"three-accounts-not-two","runs":[{"text":"Three accounts, not two"}]},{"t":"p","runs":[{"text":"The earlier test was two accounts: log in as B and try to reach A’s things. With shared resources, the test needs three."}]},{"t":"ul","items":[[{"text":"A","b":true},{"text":", the customer who made the booking."}],[{"text":"B","b":true},{"text":", a member of the business."}],[{"text":"C","b":true},{"text":", a member of a different business."}]]},{"t":"p","runs":[{"text":"A and B should see it. C should not. Then the actions: A can cancel but not edit; B can edit; C can do nothing. Write this as a script that runs against the preview deployment, one request per cell, checking the status code, and run it before every release. It is thirty lines and it is the test that catches the agent making the page work by removing the check."}]},{"t":"h2","id":"lists-are-routes-too","runs":[{"text":"Lists are routes too"}]},{"t":"p","runs":[{"text":"The single-row check is where people look. The list is where the leak usually is. "},{"text":"GET /api/bookings","code":true},{"text":" returning every booking, with the filtering done on the page, is the same hole with a wider mouth; the Network tab shows the whole table. Every list query starts from the user: their bookings, or the bookings of businesses they are a member of, joined through the membership table. Never the full table filtered afterwards in JavaScript."}]},{"t":"h2","id":"actions-the-model-wires-wrongly","runs":[{"text":"Actions the model wires wrongly"}]},{"t":"p","runs":[{"text":"Three requests, each reasonable, each producing a hole when built quickly."}]},{"t":"p","runs":[{"text":"“Let staff manage bookings.” Built as a "},{"text":"role","code":true},{"text":" column on users, and every check becomes "},{"text":"if (user.role === \"staff\")","code":true},{"text":", which makes staff at one business able to manage bookings at all of them. The role belongs on the membership, scoped to the business."}]},{"t":"p","runs":[{"text":"“Add an admin who can see everything.” Built as "},{"text":"isAdmin","code":true},{"text":" on the users table, set from the profile form, which is the mass-assignment hole in the lesson after next. Admin belongs in an environment allowlist, not in a column a form can reach."}]},{"t":"p","runs":[{"text":"“Let users share a document by link.” Built as a public route keyed by the document id, the guessable id again. A share link is a separate random token in a "},{"text":"shares","code":true},{"text":" table, revocable, with its own expiry."}]},{"t":"h2","id":"the-question-for-the-agent","runs":[{"text":"The question for the agent"}]},{"t":"p","runs":[{"text":"Not “is this secure”, which produces reassurance. For each route: “which line decides whether this user may perform this action on this row, and what does it return when they may not”. If the answer for any route is a line that compares only "},{"text":"user_id","code":true},{"text":", ask whether anyone else should see that row. If the answer is that the front end hides the button, there is no check."}]}],"check":{"question":[{"text":"A booking is visible to the customer who made it and to the business it was made with. The route checks booking.userId === session.userId. The business owner opens the booking and gets 404. The agent proposes removing the check so the page works. What is the right change?"}],"options":[{"text":[{"text":"Remove the check for the business’s routes only, since business owners are trusted users who have signed an agreement with the platform and are contractually accountable for how they use it."}],"correct":false,"tempting":[{"text":"Trust is not a check; any signed-in business could then read every booking on the platform, not only its own."}]},{"text":[{"text":"Keep the check and give the business owner a separate admin page that lists all bookings."}],"correct":false,"tempting":[{"text":"An admin page that lists all bookings shows every business every booking; the scope problem has moved, not gone."}]},{"text":[{"text":"Add the business owner’s id to the booking row as a second owner column and check either one."}],"correct":false,"tempting":[{"text":"Works for one owner and breaks when the business has staff; membership belongs in a table, not in columns added per person."}]},{"text":[{"text":"Replace the ownership test with a can() function that allows the customer, or a member of the business the booking belongs to, and returns 404 otherwise."}],"correct":true,"tempting":null}],"order":[2,3,1,0],"why":null},"prev":{"slug":"security-holes-ai-code-has-by-default","title":"The security holes AI code has by default","minutes":10},"next":{"slug":"the-built-bundle-and-the-history","title":"Secrets in the built bundle, and in the history","minutes":8}}}