Permissions for Internal Tools: Who Sees What, and How to Prove It
A support lead asks for a customer lookup screen. Simple enough: email in, account status, plan, last four invoices, open tickets. Two weeks later someone notices that the screen also returns the stripe_customer_id, the internal churn risk score, and a notes column where an account manager once wrote something about a customer's legal threat. Nobody granted that. It rode along because the query said SELECT * and the tool had one permission level: open or closed.
That is the whole problem with internal tool permissions in one paragraph. Internal tools are built fast, usually by one person, usually against a database connection that has more reach than the tool needs. The access model is an afterthought bolted on at the door, and the door is the only place it exists.
This guide is about doing it the other way around: designing internal tool permissions from the data outward, inheriting whatever the source system already decided, and leaving an audit trail that answers "who saw this record, and when" without a forensic project. It applies whether you build with React and a hand-rolled API, a low-code builder, or a declarative app platform.
The four questions any access model has to answer
Most internal tool permission conversations collapse into "who gets the admin role." That is one question out of four, and it is the least interesting one. The four that matter:
Who can open this tool at all? Coarse, cheap, and the only one most builders implement. It is a page-level gate.
Which rows can they see inside it? This is where real exposure lives. Two support agents open the same customer console. Should the EMEA agent see North American accounts? Should the contractor see any account with a confidential flag? Row scoping is invisible in a screenshot and catastrophic when wrong.
Which columns can they see? Salary, cost basis, margin, personal contact details, internal scoring, free-text notes. A column is not sensitive because of its name, it is sensitive because of what it says. Free-text is the worst offender because nobody profiles it before shipping.
Which actions can they take, and under what conditions? Reading a refund queue and issuing a refund are different privileges. So are issuing a $12 refund and a $12,000 one. Action permission almost always needs a threshold, not a boolean.
If your internal tool permissions only answer the first question, you do not have an access model. You have a login page.
Inherit internal tool permissions, do not invent them
The most common mistake is inventing a brand new permission scheme inside the internal tool. You end up with a roles table nobody maintains, drifting quietly away from the systems it was supposed to mirror. Someone leaves the company, gets deprovisioned in Okta and Salesforce and Slack, and still has an active role in the ops console because that console kept its own list.
The better default: inherit. Let the source system stay the authority and make the tool a projection of it.
Practically, inheritance has three shapes.
Identity inheritance. The tool does not maintain its own users. It authenticates through your identity provider and reads group membership from there. If you are removed from the support-tier2 group, you lose the tool. Nobody has to remember.
Scope inheritance from the source system's own model. Salesforce already knows which accounts a rep owns. HubSpot already has team-based record visibility. Jira already has project permissions. Your tool should carry a filter derived from that, not a hand-maintained mapping. When the tool queries through a connected tool's API using per-user credentials, this happens naturally: the API returns what that user is allowed to see, and nothing more. That is the strongest form of inheritance available, and it is underrated.
Database-level inheritance. When the tool reads from a database directly, the equivalent is row-level security policies in the database itself, plus a read-only role scoped to specific schemas or views. The tool then cannot over-read even if the query is careless, because the database will not serve rows the connection is not entitled to.
That last point deserves emphasis because it has become the defining failure of the AI-built app era. Published security research scanning AI-generated applications has repeatedly found a meaningful share shipped with database row-level security either disabled or absent entirely, leaving anonymous keys able to read whole tables. The generated frontend looked correct. The data layer was wide open. If you are building anything with AI assistance, read the security questions to ask about AI-generated apps before you connect a production database.
Where internal tool permissions actually get enforced
Enforcement location is the single most useful thing to be strict about, because it determines what happens when something else goes wrong.
There are four layers where you can enforce internal tool permissions, and they are not equivalent.
| Enforcement layer | What it protects against | What it does NOT protect against | Cost to change | Use it for |
|---|---|---|---|---|
| UI (hide the component) | Accidental viewing, clutter, confusion | Anyone who inspects the network tab, any API call made directly, any export | Trivial | Reducing noise, never for secrets |
| Application query layer (add a WHERE clause) | Careless reads, most day-to-day over-exposure | A second query path, a new screen written later that forgets the clause, an admin script | Low | Scoping within a trusted app, plus a lower layer |
| Database (row-level security, views, column grants) | Every path that uses that connection, including future ones and ad hoc queries | Access via a different connection with a superuser role | Medium, needs migrations and testing | Anything genuinely sensitive: PII, financials, HR data |
| Source system API with per-user credentials | Everything, because the source decides and logs it | Latency, rate limits, and the source's own permission gaps | Medium to high | Records the source system already governs well: CRM, tickets, repos |
The rule this table encodes: UI hiding is presentation, not permission. If the only thing standing between an agent and the salary column is a component that was not rendered, the salary column is not protected. Push the boundary down at least one layer below the surface where the user is.
The corollary is that you want the layers to agree. If the database refuses rows the UI happily requests, you get empty states and confused users. If the UI hides a column the query still returns, you get a payload leak. Design them together, and make the lowest layer the strict one.
Column-level thinking, and the profiling step people skip
Rows get attention. Columns rarely do. The practical failure is not a table nobody should see, it is a table almost everybody should see with two columns almost nobody should.
Before you decide column permissions, you have to know what is actually in the columns. Not the schema, the contents. A column called notes is a schema fact. That the median notes value is 340 characters and 8% of them contain an email address is a data fact, and only the second one tells you whether to expose it.
This is where profiling the real data before designing the screen changes the outcome. When Skopx builds an app, it runs the query first and reads a profile of what came back: column types, value ranges, text lengths. The original purpose is layout, so a column of 400-character strings does not get jammed into a narrow table cell. But the same profile is the best permission input you will get, because it tells you which columns are long free text (highest leak risk), which are high-cardinality identifiers (often internal keys you did not mean to publish), and which are near-constant (usually harmless). The same idea is worth doing by hand if your stack does not do it for you. There is more on this in designing apps on connected data and on why unprofiled layouts go wrong in why AI dashboards look wrong.
A workable column policy for most internal tools:
- Default deny on free-text fields. Include them only when someone names the use case.
- Never select identifier columns from payment or auth systems unless a workflow needs them, and if it does, show a truncated form.
- Treat any derived score (risk, propensity, health) as sensitive by default. People act on scores they do not understand, and customers react badly to seeing them.
- Financial columns get their own tier. Cost, margin and comp are almost never appropriate at the same level as revenue.
Actions are a separate permission system
The moment your internal tool can do something, the access model needs a second half. Reading a stalled-shipment list is a view. Emailing all affected customers is an act with consequences that do not roll back.
Three properties make action permissions survivable.
Explicit invocation. An action fires because a human clicked a button, not because a page loaded or a filter changed. This sounds obvious and is violated constantly by tools that "refresh" by re-running a mutation.
Confirmation with the payload shown. The confirmation dialog should state exactly what will happen to exactly which records. "Send credit memo to 47 customers" is a confirmation. "Are you sure?" is not.
Thresholds and scope, not just role membership. The same person can be allowed to refund up to $500 and required to escalate above it. Encode the threshold in the action definition, not in a policy document.
In Skopx apps, every action button is an explicit click with confirmation, and the action runs through a connected tool with its own permission model on the far side. That second point matters more than the first: if the Slack connection cannot post to #finance, the button cannot either, no matter what the app definition says. Inheritance again.
The honest boundary: consoles are not systems of record
Here is a limit worth stating plainly, because it changes what your permission model even needs to cover.
Skopx apps read from connected systems and take actions through connected tools. They do not store their own records, and there is no form component that creates new data. So the app is a console, a dashboard, a review queue, an admin view over data that already lives somewhere else. If you need the tool to be the system of record, an invoicing system that generates invoices, an applicant tracker that stores candidates, a delivery system that creates deliveries, that is not something it does today. Build that elsewhere and point a console at it.
The permission consequence is genuinely useful: when the app owns no data, there is no second copy to secure, no shadow database drifting out of sync with production, no separate retention policy, no new place for PII to land. Your access model reduces to two surfaces: what the read connection is allowed to return, and what the action connections are allowed to do. That is a far smaller thing to reason about than a full application with its own storage.
It is also a constraint. Anything that requires capturing new structured input from a user has to happen in the system that owns it. The related tradeoffs between generated code and declarative definitions are covered in declarative apps versus generated code.
Sharing, and the quiet risk of "share with the org"
Most internal tool platforms offer two visibility states: private to you, or shared with everyone in the organization. Two states are simple and simple is good, but the org-wide state hides a real risk.
An app built by a finance analyst, tested against their own database connection, then shared org-wide, is now being opened by people whose access to the underlying data may be very different from the builder's. If the app runs on the builder's connection, everyone in the org effectively borrows finance's read access for the duration of that screen. If it runs on the viewer's connection, half the org sees empty tables and files support tickets.
Neither outcome is wrong in principle, but you have to know which one you have chosen. Ask three questions before any internal tool goes org-wide:
- Whose credentials execute the query, the builder's or the viewer's? This is the single most important fact about any shared internal tool and it is almost never documented.
- If the builder's, is the underlying query scoped tightly enough that org-wide exposure is acceptable? Not "is it useful," is it acceptable to everyone, including the newest contractor.
- What happens when the builder leaves? Tools running on a departed employee's connection either break at the worst moment or, worse, keep working.
A simple discipline handles most of this: shared apps run on narrow, purpose-built read-only views rather than broad table access, and the view is defined with org-wide exposure assumed. Build the query as if everyone will see it, because in the shared state, everyone will.
The audit trail: what you need before someone asks
Auditability is the part everyone defers and everyone eventually needs, usually during a security questionnaire, a customer's vendor review, or an incident where the question is "did anyone look at this record."
You need to be able to answer three questions after the fact, without heroics.
Who opened which tool, when. The cheap one. Almost every platform logs it. Retention is the thing to check: a 30-day log answers nothing about an incident discovered in month four.
What data was returned to whom. Much harder, and this is where most internal tools fail entirely. Logging that a user opened the customer console tells you nothing about whether they saw the account under investigation. What you want is the query plus its parameters plus the scope filter that was applied, logged per execution. Not the result set, which is expensive and creates a second copy of the sensitive data, but enough to reconstruct what the result set would have contained.
Which actions ran, by whom, with what payload, and what the downstream system said. This one you get largely for free when actions run through connected tools, because the destination system logs its own side. A message posted to Slack exists in Slack's audit log. A HubSpot record update appears in HubSpot's timeline. A Stripe refund is in Stripe's dashboard with the API key that issued it. The trail lives where the consequence lives, which is exactly where an auditor will look for it.
That last property is a real argument for action-through-connected-tools over action-through-custom-code. Custom code writes to the destination with a shared service credential and your only record is whatever you remembered to log. Connected tools carry attribution into the destination system.
There is also a platform floor to insist on underneath all of this, whoever you buy from. Ask for AES-256 encryption at rest, TLS 1.3 in transit, per-organization row-level isolation so one tenant's queries cannot reach another's rows, SOC 2 controls in place, and a written commitment that customer data never trains models. Then ask the question vendors answer least readily: how long are access and action logs retained, and can you export them. That retention number is what determines whether your audit trail exists on the day you need it.
A practical rollout sequence
If you are adding permissions to internal tools that currently have none, doing it all at once fails. This order works.
Week one: inventory the connections, not the tools. Every internal tool is only as scoped as its database user or API token. List every credential your tools use and what it can reach. Most teams find at least one tool running as a superuser.
Week two: narrow the credentials. Create read-only roles per tool or per tool family. Grant on views, not tables, wherever a view is feasible. This single step eliminates the majority of over-read risk and requires no application changes.
Week three: add row scoping at the database. Row-level security policies keyed to the authenticated user's org, team or region. Test with a real non-admin account, not a superuser, because superusers bypass policies and everything will look fine while nothing is enforced.
Week four: classify columns and prune. Go screen by screen. Remove what nobody named a use case for. Replace SELECT * everywhere. This is tedious and it is where the actual leaks get closed.
Week five: audit the actions. For each button, write down who may click it, any threshold, and where the record of the click lands. Anything without a landing place gets one.
Ongoing: review the shared set quarterly. Org-wide tools accumulate. Someone should re-ask, once a quarter, whether each one still deserves that setting.
Related reading if you are earlier in the build: taking an AI prototype to production covers the hardening checklist around this, and governance for AI-built apps covers who approves what. If you want to see how this plays out on the pricing side of a platform decision, the Skopx plans page lays out what is included per seat.
FAQ: internal tool permissions
Should internal tools use their own roles or inherit from the identity provider?
Inherit, in nearly every case. A tool-local roles table is a maintenance liability that drifts from reality the first time someone changes teams. Inherit identity and group membership from the IdP, and inherit record scope from the source system's own model where you can. Reserve tool-local roles for genuinely tool-specific distinctions that no upstream system knows about, such as who may click a particular action button. Even then, keep the list short enough that someone could review it in five minutes.
Is row-level security in the database enough on its own?
It is the strongest single layer, and it is not sufficient alone. Row-level security governs which rows a connection may return. It does not restrict columns unless you also use column grants or views, it does not govern actions, and it can be bypassed by any connection using a role that ignores policies, which is exactly what most migration scripts and admin tools use. Treat it as the floor that catches careless queries, then add column scoping and action permissions above it. And always test policies with a genuinely non-privileged account.
How do I handle contractors and temporary access?
Time-bound the group membership in the identity provider, not the tool. Contractors go into a group with an expiry, the tool inherits, and access ends without anyone remembering. For data scope, give contractors a separate scope value rather than a subset of an employee scope, because subsets are where exceptions creep in. Assume every internal tool a contractor can open will be screenshotted, and make column decisions on that assumption.
What is the minimum audit trail that will satisfy a security review?
Realistically: authentication events, tool access events with timestamp and user, action invocations with actor and payload summary, and a stated retention period longer than your incident discovery window. Reviewers care less about volume than about whether you can answer a specific question about a specific record on a specific date. Practice answering one before someone asks, and you will find the gap in about ten minutes.
Do read-only tools really need this much attention?
Read-only means no writes. It does not mean no exposure. Most internal tool incidents are read incidents: someone saw a customer list, a comp table, a set of notes. A read-only console over a broad connection is one of the highest-exposure things in a company, precisely because it is treated as harmless and shared freely. The write path gets scrutiny because it can break things. The read path deserves scrutiny because it cannot be undone.
How do permissions differ when the tool reads a database versus a connected SaaS tool?
Database reads put the enforcement burden on you: you define the role, the views, the row policies. Connected SaaS reads let you borrow the vendor's model, which is usually more mature than anything you would build, and which logs on their side. Prefer the connected tool for records that system governs well, such as CRM ownership or ticket visibility. Prefer the database when you need joins across systems or performance the API cannot give you, and accept that you own the access design in that case. Many real tools do both, and the trap is assuming the strict one covers the loose one. It does not. Scope each path on its own terms.
The short version
Design internal tool permissions from the data outward. Inherit identity from your IdP and record scope from the systems that already govern those records. Enforce at least one layer below the UI, and make the database or the source API the strict layer. Profile real column contents before deciding what to expose, because free text and derived scores leak more than schema names suggest. Treat actions as a separate permission system with explicit clicks, real confirmations and thresholds. And decide, deliberately, whose credentials execute a shared tool's queries.
Do that and the audit trail mostly builds itself, because the enforcement is happening in systems that log. Skip it and you will be reconstructing access history from application logs that were never designed to answer the question. The tradeoffs of replacing SaaS with internal tools shift meaningfully once you count this work honestly, and you should count it before you start.
Skopx Team
The Skopx engineering and product team