The problem

The bid-response area is for a team of under ten people inside a company of a few hundred. It holds live tenders, the shared corpus of approved answers that gets quoted verbatim into customer-facing documents, and the most expensive model calls in the product: whole-document passes, not chat turns.

It was also, at this point, a feature that had been built fast and demonstrated to stakeholders. The access story consisted of a role name, a build-time flag in the frontend, and an admin tab. The ask was to make it real before more people were onboarded. What made that non-trivial was that the four things being conflated bound completely different quantities.

01

Forty-four routes with only a session

The questionnaire tree, holding the shared answer corpus, the returnables and the AI answering endpoints, had no entry in the permission catalogue at all. Every one of its routes accepted any authenticated user in the company, and none of the by-ID routes checked ownership, so a shared URL was a shared tender.

02

A flag is not a permission

Navigation and routing were gated on a compile-time boolean with no user in it. On meant everybody could walk into the area and collect 403s and empty screens; off meant nobody, including the bid team. There was no third state available.

03

Nothing bounded spend

Three rate limiters existed in the codebase and were attached to the public and versioned route groups. Every bid route lives in the authenticated group, which had none. The most expensive endpoints in the product had no ceiling of any kind.

04

One list looked private and was not

Exactly one surface scoped its results to the creating user. With no ownership check on any by-ID route that was a privacy illusion, and it actively hid a colleague’s uploaded returnables from everyone who needed to work on them.

What I built

This one shipped. The route guards, the limiter, the list change and the frontend gate are all in the codebase. The two pieces that are configuration rather than code are stated as residual below, because they cannot be verified by reading it.

Already existed
  • A role and permission system with a resource-plus-action model, middleware that fails closed, and a permission cache
  • A bid resource with read / create / update / delete actions, already applied across most of the bid routes
  • A seeder that provisions roles and permissions on boot
  • Three rate limiters, built and working, attached to the public and versioned route groups
  • A build-time feature flag switching the bids navigation group and routes on and off
  • Per-bid membership plumbing on the model: assignment fields, unused
What I added
  • Permission guards on the forty-four questionnaire routes that had none, folded under the existing bid resource rather than a new one
  • A fourth rate limiter sized off a measured real workload, applied per route to the document-sized model passes only, and stacked after the permission guard
  • The questionnaire list unscoped from per-user to a shared library, with the creator retained as displayable provenance
  • A frontend gate that is flag AND permission, with an explicit checking state, failing closed on any error
  • The rejected designs written up with their costs, covering per-user libraries, per-user templates and per-bid membership, so nobody re-proposes them as improvements
  • The parts of the control that live outside the repository named as such, including the two that can silently make it decorative
Four gates that get discussed as one, and the part that isn’t in the repository
One request into the bids areaeach gate bounds a different question, and only one of them bounds spendBuild-time feature flagno user dimension at allon its own this has exactly twosettings: everyone sees the area, ornobody does, including the team itwas built forAuthenticationis this a signed-in personwhat 44 routes had, and nothing else.“a few hundred people” is the blastradius of a route with only thisRoute permissionbounds WHO, fails closedfails closed and is cached, which iswhy enforcement and seeding shiptogether: otherwise the team is thefirst thing locked outRate limitbounds HOW MUCH, nothing else doesstacked AFTER the permission gate, soa 429 only ever reaches someonealready allowed in. fails open: losingthe cache degrades metering, not thefeatureHandlershared by design: every holder sees every bidinside the role there are no silos,so shared also means mutual deleterights, which is an acceptedconsequence, written downNot verifiable by reading the repository: any one of these makes the gates decorative· who is actually assigned the role, administered by hand· administrators already hold all of these, and the seeder re-adds them on every boot· the identity provider’s default role: aim it at a privileged role and none of this holds

Five problems worth describing

01

Restricting a role does nothing to a route that has no permission on it

Symptom

The stated goal was “restrict this area to the bid team”. The role existed, most of the top-level bid routes already carried a permission, and the admin tab could grant it. It looked like a configuration task.

Diagnosis

The questionnaire tree, which is where the actual content lives, had no resource in the permission catalogue whatsoever. Forty-four routes, authentication only. A route that asks only are you signed in has the entire signed-in population as its blast radius, and that population was three orders of magnitude larger than the intended audience.

Enumerating what was reachable is what made the ticket urgent rather than tidy. A bulk delete of the shared answer corpus. A silent rewrite of an approved answer that gets quoted verbatim into a customer-facing document, with no approval step between the edit and the quote. An unmetered full-document model pass per call. And a read of any tender by id, because no by-ID route checked ownership, so a link pasted into a chat was a working grant.

Decision

Fold all of it under the existing bid resource rather than mint a questionnaire resource of its own. The team that may see a tender is the same team that may see its questionnaires; a second resource would have meant a second permission to seed, a second thing to grant, and a new way for the two to disagree. Fewer moving parts, and one less seeding step, which matters because seeding is where the lockout risk lives.

That ordering is the part worth stating. The middleware fails closed and the permission lookup is cached, so enforcement and provisioning have to ship in the same deployment, with the cache invalidated. Ship the guards first and the first thing the change does is lock out the team it was written for.

Inside the role there are deliberately no silos: every holder can see and edit every bid. That is what the team wanted, since they hand tenders between each other constantly, but it also means mutual delete rights, and a wrong click is not recoverable through the interface. This is written down as an accepted consequence of the design rather than left to be discovered later as a bug report. The honest framing is that the boundary is around the team, not within it.

02

Two deployment states, neither of which was the decision

Symptom

The frontend decided whether to show the bids navigation group and register its routes from a build-time flag. The flag was on in the test environment and off in production, and the change request was “turn it on in production for the bid team”.

Diagnosis

That request is not expressible. The flag has no user dimension, so it has two settings. On, and every one of a few hundred users sees a navigation group, clicks into it, and is served an empty screen or a 403 by the backend, so the area looks broken to almost everyone who can see it. Off, and the team it was built for cannot reach it either.

The flag was doing a job it is good at, does this feature exist in this build, and being asked to do a job it structurally cannot: is this feature for you. Those are different questions and they wanted different mechanisms, not a better value for the same one.

Decision

Gate on flag and permission, and keep the flag rather than removing it, because an outer kill switch that turns the whole area off in a build without touching anybody’s roles is worth having on a young feature.

Two details decide whether that works in practice. The permission check is asynchronous, so without an explicit checking state the navigation group renders for everyone on first paint and then vanishes, which is worse than either original state, because it advertises the feature to exactly the people who cannot use it. And the route guard has to fail closed: a network error while checking is not permission, and the tempting catch that lets the user through to “let the backend decide” hands them a broken page instead of a redirect.

What a build-time flag can express, versus what the requirement was
Flag only: two states
flag on  → all few hundred users see it
         → 403s and empty screens

flag off → nobody sees it,
         → including the bid team

the requirement was neither
Flag AND permission: three
flag off      → area gone from the build
                (kept: outer kill switch)

flag + perm   → the team
flag, no perm → invisible, not broken

async check ⇒ needs a “checking” state
error       ⇒ fail closed, redirect
03

Hiding a list does not hide what the retriever reads

Symptom

The natural next request after “restrict the area” is “and scope the content within it”: each person’s own content library, their own answer templates, membership per bid. Three plausible features, all rejected. The reasoning is the useful part, because each one fails for a different reason.

Diagnosis

A per-user library would have been fake. Hiding documents from a list does nothing to their chunks. The retriever filters on document type, content class and customer, never on uploader, so everyone’s documents keep surfacing in everyone’s generated answers, without attribution and without a visible origin. Making it real means an uploader predicate on every chunk query, or per-user indexes. And per-user libraries guarantee duplicate uploads of the same certificate: embedding spend paid repeatedly, and a retriever choosing between near-identical chunks. A shared corpus is worth more than the sum of its parts precisely because it is shared.

Per-user templates fail on a different axis. The mechanism is a hard singleton keyed by type, so it is not a small change, but the real objection is that the shape of a generated report would then depend on who clicked the button. A template encodes how the company qualifies a bid. That is a standard, not a preference.

Per-bid membership is the one that could be built, since the assignment fields already exist on the model, and the team explicitly does not want it. They pass tenders between each other daily. Enforcing membership would mean a membership check inside every by-ID handler in the area, which is real middleware work for a boundary nobody asked for.

Decision

None of the three. Written up as rejected-with-reasons rather than left off the list, because all three sound like security improvements and one of them, the library, would have been a reduction in security. A list that looks private while the retrieval path is unchanged teaches people to trust a boundary that is not there. The assignment fields stay as what they are, which is assignment for coordination, not access control.

The general form of this, which I would now apply before agreeing to any scoping request: ask which code paths read the data, not which screens show it. A list endpoint, a retrieval pipeline, an export, a cache and a background job are five readers, and a filter added to one of them buys exactly one of them. If the answer is “we would only be filtering the list”, the feature is a label on a boundary rather than the boundary.

04

Access control bounds who, and nothing was bounding how much

Symptom

Found while auditing the routes rather than as the assigned task: three rate limiters existed, configured and working, and every one of them was attached to the public route group or the versioned group. Every bid route lives in the authenticated group, which had none.

Diagnosis

So the most expensive endpoints in the product had no ceiling. Not the chat turns, but the document-sized passes: extract every requirement from a tender, generate a whole response grid, answer every question in a returnable workbook. A retry loop in a browser tab, or one enthusiastic person clicking generate repeatedly on a slow run, fans out a document-sized model pass per second, and the only place that becomes visible is the bill.

A permission gate cannot help with this. It answers may this person do this at all, which is already yes. The quantity question has no other mechanism behind it.

Decision

A fourth limiter, sized off a real workload rather than a round number. A genuine bid kickoff was measured at roughly a dozen of these calls, so the ceiling is twenty per ten minutes per user, comfortably above real use and far below a loop.

Three deliberate choices in how it is placed. It is stacked after the permission guard, so a rate-limit response only ever reaches somebody already allowed in, and an unauthorised caller gets a 403 rather than a signal about which endpoints are worth hammering. It is applied per route to the document- and bid-sized passes only, and deliberately not to the single-answer assist endpoint, because that is one click for one answer and a batch-sized ceiling would cut off somebody legitimately polishing a page. And it fails open on a cache error: losing the shared cache should degrade metering, not take bid generation down.

The same audit found that chat streaming on that group is also unmetered, and it is a larger spend surface than anything here. It is out of scope for this change and named rather than silently left. The argument for scoping it out is that a per-user chat ceiling is a product decision about how people use the main feature rather than a guardrail, and it should not be smuggled in under a bids ticket.

05

The half of the control that is not in the repository

Symptom

With the guards, the limiter and the frontend gate in place, the change is complete as code and the question “is this area restricted to the bid team?” still cannot be answered by reading the codebase.

Diagnosis

Three facts decide the real state of every gate above, and all three live in configuration.

Who actually holds the role is hand-administered, and the honest answer at the time of the change was that nobody had checked recently. Worse, administrators already hold every one of these permissions, and the seeder tops them up on every boot, so removing one by hand does not stick past the next deployment, and every administrator is inside the boundary whether or not that was intended.

And the identity provider creates users on first sign-in with a default role. If that default points at a privileged role, the entire restriction is decorative for every new joiner, and no amount of middleware in the repository would show it.

Decision

State all three as residual, in the same document as the code change, with the verification steps written out: read the current role assignments, check what the default role for auto-created users actually is, and decide deliberately whether administrators should hold bid permissions rather than inheriting that from the seeder’s convenience.

The point is not that configuration is unknowable. It is that a change which looks finished in a diff can be zero per cent effective in production, and the write-up is the only place that can say so. A control is the code and the configuration, and claiming the first as the whole thing is how an area ends up believed to be restricted when it is not.

One boundary that is genuinely enforced and worth stating positively: none of this depends on email-domain checks, group names or client-side role strings. The gates read from the same server-side permission store the rest of the platform uses, and the frontend check is an optimisation for what the user sees, never the enforcement. Every route re-checks. That was existing platform design rather than my contribution, and it is why the fix was attaching middleware rather than building an authorisation layer.

Where it landed

44routes in the questionnaire tree that required only a valid session, including a bulk delete of the shared answer corpus
3rate limiters already in the codebase, none of them attached to the route group every bid endpoint lives in
2deployment states a build-time flag can express, and the requirement was neither of them
0ownership checks on by-ID routes inside the role: shared by design, which is why the per-user list was an illusion
Verified
  • Every route in the questionnaire tree now carries a read, create, update or delete permission under the existing bid resource. No new resource, no second thing to seed
  • Enforcement and provisioning ship together with the permission cache invalidated, because the middleware fails closed
  • A fourth rate limiter, sized off a measured real kickoff, applied per route to the document-sized model passes and stacked after the permission guard
  • The limiter deliberately omits the single-answer assist endpoint, and fails open on a cache error so lost metering does not become lost generation
  • The questionnaire list is a shared library rather than per-user, with the creator kept as provenance the interface can display
  • The frontend gate is flag AND permission, with an explicit checking state so nothing flashes in for users who cannot use it, and it fails closed on error
  • Per-user libraries, per-user templates and per-bid membership are all written up as rejected with their specific reasons, rather than left as open ideas
What this doesn't solve
  • Two of the five items are configuration, not code, and cannot be verified from the repository: which people hold the role, and what the identity provider’s default role for auto-created users is. If that default is privileged, the whole restriction is decorative for new joiners.
  • Administrators hold every bid permission and the seeder re-adds them on every boot, so removing one by hand does not survive a deployment. Whether administrators should be inside this boundary is a decision nobody has made explicitly. It is currently inherited from the seeder.
  • Inside the role there are no silos: mutual delete rights on live tenders, with no interface-level recovery. Accepted deliberately because the team hands work between each other constantly, but accepted rather than solved.
  • Chat streaming on the same route group remains unmetered and is a larger spend surface than anything metered here. Named and scoped out, not fixed.
  • The rate limit is per user, which bounds a runaway browser tab. It does not bound the team collectively, so a genuine cost ceiling for the area is still absent.
  • Nothing here adds an audit trail of who changed which approved answer. The corpus is now editable only by the team rather than by everyone, which reduces the population but does not record the edits.

What I'd do differently

Read the route table, not the feature description. The area had a role, a flag, an admin tab and a plausible story about who could use it, and forty-four of its routes had none of that applied. The whole finding came from listing every route with its middleware chain and looking for the ones with a short chain. That was half an hour of work that no amount of reasoning about the design would have produced. I would now start any access-control ticket by generating that table.

Ask what each gate bounds before adding another one. Flag, permission, rate limit and role assignment were being discussed interchangeably as “locking it down”, and they bound whether it exists, who may use it, how much they may use, and who is actually included. Three of the four were missing or misattached, and it was only separating the questions that made that visible. Adding a stricter version of the gate that was already there would have changed nothing.

Treat “scope it per user” as a claim to be checked against every reader. The per-user library sounded like the security-conscious option and would have been a net loss: a boundary visible in the interface, absent from the retrieval path, and therefore trusted. The test that settles it is not whether the list looks right. It is which code paths read the data, and the retriever never had an uploader filter to remove.