Access Control · Internal platform

Adding a permission shouldn't mean altering the user table

Permission models in internal tools usually begin as a checkbox on the user record, and that holds up until there are forty screens. By then the shape is load-bearing: adding a permission means altering the table every account lives in, and the question of who can open a given screen has no answer except reading the code of the screen. Nobody rewrites that while the tool is in daily use — which is precisely why it needs rewriting.

55 operational screens
2 ranked privilege tiers
17 allowlisted unauthenticated calls
Confidential client & operation

Built for the internal operations platform of a contract-logistics site, where floor operators, analysts, leads and site management all work from the same sign-in.

Access control: bit columns before, three tables and one rule afterThe top band shows the old user table: user id, name, a bcrypt password hash column, and a bit column per permission, with a line noting that a new permission meant altering that table and that roles were discovered by reading the schema catalogue. The middle band shows the replacement: users joined to roles through user_roles, and roles joined to a catalogue of fifty-five screens through role_screens, with a note on cascading deletes, additive table creation and the bcrypt hashes, with SHA-3 on newer projects. The bottom band holds three rules: permissions resolved once at sign-in into the session, a manager check that validates a second person without elevating the first, and a suspension flag file that fails closed while leaving the metrics endpoint open.BEFORE · A ROLE WAS A COLUMN ON THE USER ROWuser_id··········name··········password_hashbcrypt··········can_receivebitcan_auditbitcan_scrapbitcan_kioskbit…more……a new permission = ALTER TABLE on the table every account lives inroles discovered at runtime by reading the schema catalogue for boolean columnsA role was a column, granting onewas a bit flip, and nothing recordedwhich screens a role opened, onlywhich flag a page happened totest. The model was neverdesigned; it accreted, onecheckbox at a time.AFTER · THREE TABLES AND ONE RULE: A GRANT IS A ROWUsersan account holdsrolesuser_rolesa grant is a rowRolesa name plus a setof screensrole_screensa screen grant is arowScreensa catalogue of 55;registering one is anidempotent insertDeleting a role cascades to its screen grants and its user assignments in one statement, so no orphaned permission is left behind. The tables createthemselves on first use and add missing columns additively, which is how the second tier arrived without a migration window. Passwords were neverthe problem: bcrypt hashes here, SHA-3 on the projects since.THREE RULES · RESOLVED AT SIGN-IN, A SECOND PERSON, FAIL CLOSEDResolved once at sign-inPermissions land in the session; everycheck after is a set-membership test withno database round trip. A change takeseffect at the next sign-in, and sessionsexpire after four hours idle. Unattendedkiosk screens reach the server through anallowlist of 17 module-and-function pairs.Manager checkA separate endpoint validates a secondperson's credentials and then does nothingelse: no session, nothing elevated; theoperator carries on as themselves. 2 rankedtiers, ranked one way: a manager passes aleadership check, leadership does not passa manager check.Suspension fails closedA flag file at the application root, written byatomic replace, answers every route with asuspension page. A file that exists butcannot be parsed keeps the servicesuspended. The metrics endpoint staysopen, so monitoring reports an outage, nota healthy silence.
Before, every permission was a bit column on the user row. After, a role is a row that maps to screens and is resolved into the session at sign-in; a manager check verifies a second person without elevating the first, and suspension fails closed.

How do you add role-based access to an app people use every day?

Move permissions out of the user table into data. Three tables replaced per-user bit columns: a role is a name plus a set of screens, a user holds roles, a screen is a catalogue row. Roles resolve into the session at sign-in, so checks cost no round trip, and self-creating tables let a second tier arrive without a migration window.

The problem

Permissions lived as bit columns on the user row. The application discovered what roles existed by querying the database's own schema catalogue for every boolean column on that table — so a role was a column, granting one was a bit flip, and creating one was a schema change to the table every account depends on. Nothing anywhere recorded which screens a role actually opened. That mapping existed only inside whichever page remembered to check the flag.

The root cause is that the model was never designed; it accreted, one checkbox at a time, as each new screen arrived.

  • A new permission required a schema change to the user table
  • The list of roles was derived at runtime by reading the database schema
  • No record of which screens a role granted — only which flag a page happened to test

What we built

Three tables and one rule, the same model we put behind internal tools with role-based logins. A role is a name plus a set of screens; a user holds roles; a screen is a row in a catalogue. Granting access became inserting a row rather than altering a schema, and deleting a role cascades to both its screen grants and its user assignments in the same statement — there is no such thing as an orphaned permission left behind.

The permission tables create themselves on first use and add missing columns additively. That is how the second privilege tier was introduced to a system already in daily use, without a migration window or a coordinated deploy. It is the everyday shape of modernizing a system while the business keeps running on it.

Page permissions are resolved once, at sign-in, into the session. Every check after that is a set-membership test with no database round trip, because an authorization check on the request path has to be effectively free — the moment it isn't, people start writing code that skips it. The cost of that choice is honest and bounded: a permission change takes effect at the user's next sign-in, and sessions expire after four hours idle.

  • Roles are declarative — saving a role replaces its entire screen list rather than patching it
  • Registering a new screen is an idempotent insert into the catalogue, not a schema step
  • The catalogue is cached for five minutes, so navigation doesn't re-query it on every page load
  • An expired session returns JSON to an API caller and a redirect to a browser, so a background fetch never has to parse a login page
  • The stored password hash is stripped from the user record before anything is written to the session
  • Kiosk screens that run unattended reach the server through an explicit allowlist of seventeen module-and-function pairs; that module's administrative functions stay behind a separate leadership check

Permission bit columns or role tables

QuestionBit columns on the user row (before)Role tables (after)
Adding a permissionA schema change to the user tableInserting a row
Listing the rolesDerived at runtime by reading the database schemaRows in the roles table
Which screens a role opensKnown only to each page that tests its flagThe role's screen list, saved whole
Registering a new screenA schema stepAn idempotent insert into the catalogue

How does a manager approve without elevating the operator's session?

Some actions need a second person, not a more privileged session. Removing an expected pallet from a load audit, overriding a scan discrepancy, forcing a failed line to pass — the operator does the work, a manager authorizes the exception, and the operator carries on as themselves. Elevating the operator's session would be the easy implementation and the wrong one, because the session is what the audit trail is written against.

So the credential check is a separate endpoint that validates a username and password through the same path as sign-in and then deliberately does nothing else. No session is created, none is modified, no privilege is handed to the browser that asked. It answers two things and nothing more — whether the credentials are valid, and whether that account holds the tier the action requires.

The two tiers are ranked, and the ranking runs one way only. A manager passes a leadership check, because a manager directs the leads. Leadership credentials do not pass a manager check. Getting that asymmetry wrong in either direction produces the kind of bug nobody finds until an override has already been authorized by someone who shouldn't have been able to.

Every authorization carries a written reason. The prompt refuses to submit without one, and the reason is stored on the record with the authorizing username and the timestamp. One fix in that area is worth naming: a later recalculation pass used to rewrite overridden rows back to a normal status and drop the badge marking them, which erased the evidence that a person had intervened at all. Overridden rows are now excluded from that pass explicitly.

An out-of-band suspension that fails closed

The platform can also be suspended wholesale, out of band. A flag file at the application root flips a request hook that answers every route with a suspension page — HTML for browsers, JSON for API paths, so a client-side fetch gets something it can actually read.

The flag is a file rather than a database row, because the check runs ahead of every single request and must never cost a round trip; each process re-reads it at most once every five seconds. It is written by atomic replace, so a half-written file can never be observed mid-write. If the file exists but cannot be parsed, the service stays suspended — a corrupt write must not silently restore access. The hook is registered ahead of the session hooks so nothing else runs, and the metrics endpoint is left open on purpose, so monitoring reports an outage rather than a healthy silence.

The instruction channel is a polled mailbox, and it is treated as one rather than trusted as one. Each message is applied exactly once, keyed on its message identifier and recorded in an audit table. Pending messages are applied oldest-first, so when two contradictory instructions are waiting the most recent one decides the final state. An optional sender allowlist rejects anything else and records the rejection. The poll query is scoped to the inbox so the system's own confirmation replies cannot re-trigger it, and only one process in the fleet runs the poll — the others learn the state from the file.

The result

Permissions became data. A role is a row, a grant is a row, and what a person can open is a query rather than a boolean column plus a page that remembers to check it. Credentials were never the problem here: passwords on this platform are bcrypt hashes, and the projects since use SHA-3. The permission model was the part that needed security hardening on an inherited codebase.

The parts we would defend hardest are the unglamorous ones. That a privileged action verifies a second person without elevating the first. That every override carries a name and a written reason that later processing cannot quietly overwrite. And that the mechanism capable of turning the whole application off fails toward off.

Questions about this build

Why resolve permissions at sign-in instead of on every request?

Because an authorization check on the request path has to be effectively free, or people start writing code that skips it. Resolving roles into the session at sign-in turns every later check into a set-membership test with no database round trip. The cost is honest and bounded: a permission change takes effect at the user's next sign-in, and sessions expire after four hours idle.

How were passwords stored on the platform?

As bcrypt hashes, and the stored hash is stripped from the user record before anything is written to the session. Credentials were never the problem this project fixed; the permission model was, because a role was a column on the table every account depends on. The projects built since use SHA-3.

Can a lead authorize an action that needs a manager?

No. The two tiers are ranked and the ranking runs one way: a manager passes a leadership check, because a manager directs the leads, but leadership credentials do not pass a manager check. The credential endpoint answers only whether the username and password are valid and whether that account holds the tier the action requires; it creates no session and modifies none.

Built with

  • Python
  • Flask
  • SQL Server
  • bcrypt
  • Gmail API
  • Server-rendered templates

On the details we left out

Client, location, system names and volumes are withheld deliberately. We're happy to go deeper on architecture and approach on a call.

Services this draws on

Legacy Modernization & Migrations

Bring decades-old systems into the modern era without a risky rip-and-replace.

See Legacy Modernization & Migrations →

Custom Software & Web Development

Websites, internal tools, APIs, and automation for teams without a dev department.

See Custom Software & Web Development →

More projects

Product · Quality & compliance

Warehouse audits that can't be quietly passed

Customer-facing quality audits ran on paper and a survey tool. We built a configurable audit platform with scheduling, escalation, approvals — and a gate that blocks sign-off when the scan doesn't match.

Read the story →
Safety · Equipment control

Equipment that locks itself out when it fails inspection

Powered industrial trucks were signed out on a clipboard, and the fall-protection harness that some of them require was a separate honour system. We built a badge kiosk where the truck you pick decides the inspection you must pass — and which refuses to release anything that fails.

Read the story →
Controls · Reverse logistics

Financial controls on material leaving the building

End-of-life material left the site on paperwork typed by hand. We rebuilt the chain end to end and added the control nobody had: detection of inventory added after the paperwork was already filed.

Read the story →
Get in touch

Got permissions living in the user table?

Tell us how access is granted today and how many screens it covers. Free assessment.

Prefer to talk?

Call 832-598-8234 or email msco@stoneagesoftware.com. Houston, Texas — serving Houston, The Woodlands, Conroe, Sugar Land, Katy, Pearland, and the Greater Houston metro.

What happens next

We read your message, an engineer replies with questions or a straight answer, and if a call would help, we book one. From the first reply on, you're talking to the people who'd do the work.

No obligation — we reply within one business day. This form sends your details to Stone Age Software LLC so we can respond to you. See our privacy policy for how we handle them. This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.