Skip to content
Adam Manuel’s “Dev Blog”Hiding a Button Does Not Protect the Data

Find your next rabbit hole

A topic, a tool, a tiny curiosity. Start anywhere.

Hiding a Button Does Not Protect the Data

On this page15

Reading paths: Start below for the plain-language explanation, or jump to the technical deep dive for implementation details and verification.

A customer dashboard can look perfectly separated while its data access is not. The screen shows only one customer's records, but a request for another record may still succeed if the server trusts the identifier it receives.

In a customer-dashboard conversation, implementation reports described tenant isolation, database policies, and tests against a disposable PostgreSQL database. The reviewed closeout reported checks and an open pull request with a merge restriction. It did not establish that the work was deployed to production.

The broader lesson is useful for any application that stores information for separate customers: ownership must be enforced where data is accessed, not merely where controls are displayed.

A tenant is an ownership boundary

In a shared application, a tenant often means a customer organization. Several people may belong to that organization and share its records. Another organization uses the same software but should not automatically see those records.

Imagine a building with several classrooms sharing one storage room. Labeling each shelf helps everyone find their materials. It does not stop someone from opening the wrong box. Access rules need to govern the boxes themselves.

Software has the same distinction. A selected organization in the navigation tells the interface what to display. The server still needs to establish that the person belongs to that organization and can perform the requested action on the target record.

Those checks should agree, but they serve different purposes.

Authentication answers only the first question

Authentication establishes who is making a request. Authorization establishes what that person is allowed to do.

A signed-in user is not automatically allowed to read every record in the database. A member of an organization is not automatically allowed to change its billing settings. A person allowed to read a document may still lack permission to delete it.

For a data operation, the application needs to connect three things: the trusted identity, the relevant organization or ownership context, and the requested action. The record identifier alone provides none of those permissions.

This is why changing a button from visible to hidden cannot be the entire protection. A client can send a request without using that button, and ordinary software mistakes can send a valid-looking request with the wrong record ID.

Keep ownership in the data model

Records that belong to an organization need a clear relationship to that organization. The relationship should be difficult to omit accidentally when creating or querying data.

For example, a support ticket might belong to a project, and the project might belong to a customer. A design must decide whether ticket access follows that relationship or whether the ticket stores an organization reference directly with consistency rules.

The right shape depends on the application. What matters is that ownership can be established reliably without guessing from a display name, URL, or currently selected tab.

Relationships between records need attention too. A request should not be able to attach one customer's ticket to another customer's project simply because both IDs exist. Valid individual records do not automatically form a valid cross-record relationship.

Use more than one enforcement point deliberately

An application can check permissions in its service layer before performing an operation. A database can also restrict which rows a connection is allowed to read or change. The historical dashboard work reported using row-level security for tenant isolation.

Row-level security means the database evaluates rules for individual records. It can provide a valuable boundary if a query accidentally omits a customer filter. Its effectiveness still depends on the actual roles, policies, and connection context used.

These layers should reinforce the same rule. They should not contain unrelated, conflicting versions of the product's permission model.

The application layer can provide meaningful errors and handle action-specific rules. The database layer can constrain data access. Both need testing under realistic access conditions; simply having policy definitions in the repository is not proof of isolation.

Background work needs an identity too

Permissions are easy to discuss for a person clicking a button. They are easier to forget when a background job exports records, updates a report, or sends a notification.

That work still operates on someone's data. It needs an explicit ownership context and an explanation of why it is allowed to perform the operation.

Passing only a record ID to a worker can make this relationship unclear. A job created under one organization might later run with a broad service identity unless the system preserves and checks the intended boundary.

Administrative operations may legitimately require broader access. They should be intentional paths with appropriate visibility, rather than the default permissions used by ordinary customer-facing work because that was easier during development.

Test two customers, not just one happy path

A test that creates a record and reads it as its owner proves the allowed path works. It does not prove that another customer is blocked.

Create two separate organizations with separate records. Then test the combinations that should fail, including direct requests for a known foreign identifier.

CaseQuestion the test should answer
Read another customer's recordIs access denied or safely absent?
Update a foreign recordDoes the data remain unchanged?
List recordsAre foreign rows excluded?
Link records across customersIs the invalid relationship rejected?
Reuse a database connectionDoes old customer context leak into new work?
Run a background operationIs its intended ownership boundary preserved?

The exact response can vary by product. The invariant is that an unauthorized request does not reveal or change the protected data. Test the data afterward, not just the status code returned to the caller.

Test the real enforcement mechanism

A mock repository can be useful for testing business decisions. It cannot establish that a real database policy behaves correctly under the application's database role.

That is why the historical use of a disposable database matters. It allows migrations, policies, and queries to be exercised together in a controlled environment.

The test setup should resemble the important access conditions. Running everything with an unrestricted administrative role can hide problems with the role the application actually uses. A test that passes because the enforcement mechanism was bypassed answers the wrong question.

This does not mean every test needs a database. Use fast local tests for local logic and targeted integration tests for the boundary whose real behavior matters.

Avoid leaking information through helpful errors

An error can reveal more than intended. Saying "That record belongs to another customer" may confirm that a particular identifier exists even when the requester should not know.

Depending on the product, an unauthorized or absent record may need a similarly limited public response. Internal diagnostics can retain more detail for authorized investigation.

The goal is not to make the application impossible to debug. It is to separate information the requester needs from information an operator needs. Log access should itself follow appropriate boundaries, since diagnostic records can contain sensitive context.

These choices belong in the design and tests so they remain consistent as new endpoints and background features are added.

Treat isolation as an ongoing invariant

Customer isolation is a rule that should remain true as the application changes. A new report, export, or administrative screen can introduce a new access path even when existing pages remain correct.

Making ownership explicit in models, queries, permissions, and tests gives reviewers a clear question to ask: where is this operation's customer boundary established and enforced? That question is more durable than any particular button or page layout.

The interface helps people understand what they can do. The data boundary makes sure the system honors that promise even when a request arrives in an unexpected way.

Technical deep dive

Plain-language bridge: Every data operation needs a trusted answer to "whose data?" and "which action?" The record ID supplied by a client answers neither question.

Diagram: Enforce access beyond the visible controls

[client: record ID + requested action]
                    |
                    v
        [authenticate principal]
                    |
                    v
       [verify tenant + permission]
                    |
                    v
       [authorized data operation]
                    |
                    v
       [database rules + constraints]
                    |
                    v
          [only permitted records]

Hiding a button is outside this enforcement chain.

This is a simplified access path. Database policies reinforce trusted application checks; privileged bypasses and background-worker access need separate verification.

Carry a trusted context to the operation

An illustrative request context contains authenticated principal, verified tenant membership, and allowed action. Construct it after authentication and membership checks; do not accept a tenant field from the body as proof of access.

For a write, constrain both the existing row and its replacement. Otherwise, a user might update an owned row to reference another customer's parent record. A composite relationship such as (tenant_id, project_id) referencing a matching unique pair can enforce tenant agreement between related records when that model fits the domain.

Understand the database policy boundary

PostgreSQL distinguishes a policy's row visibility condition from its check on new row values. Table owners normally bypass row security; superusers and BYPASSRLS roles also bypass it. FORCE ROW LEVEL SECURITY can subject the owner to policies, but it does not remove those other bypasses. See the PostgreSQL row-security documentation.

That means a successful test run as an administrative role cannot establish the behavior of the application role. Test with the actual privilege shape intended for normal use, and review every applicable policy rather than checking that one reassuring policy exists.

Keep pooled connections from carrying old identity

A shared connection pool reuses database sessions. If tenant context is stored on a session, failure to reset it can expose the next request to the previous tenant's state. One possible design sets context locally within a transaction, performs all relevant queries there, and ensures rollback on error.

This context is safe only if trusted application code derives it from verified identity. A setting that an untrusted caller can freely choose is not an authorization boundary. Connection-context handling complements permissions; it cannot replace them.

Test a matrix of operations and identities

For tenants A and B, test reads, list filters, inserts, updates, deletes, and cross-tenant references. Repeat with missing context, an expired membership, and reuse of the same connection after a failed transaction. Assert both the response and unchanged protected data after rejected writes.

Check background workers separately because they may use different credentials or context initialization. Test any intentional administrative path as a distinct capability.

These are proposed boundary tests. The history reported database isolation checks; it does not establish that every policy and pooled-connection case described here was already covered. That distinction keeps technical depth separate from invented coverage.

You made it. Thanks for reading.