Connect Claude Code to your database — without handing over the keys
AI · Aug 2026 · 17 min read
Reusing your app's own role rows is the right instinct, and it still leaves the hard question unanswered: which roles? Hardcode it and the first customer who disagrees files a ticket you cannot close.
An MCP server that reuses your product's login is doing the easy half. A caller presents an ordinary token, you look up their memberships, and they reach exactly the tenants those rows entitle them to — revoke someone in the product and they are revoked in the connector, with nothing to keep in sync. That part is genuinely simple, and if you have already stood a server up you have probably written it.
The half that is not simple is the threshold. Your app has five account tiers; which of them may point an AI client at the whole tenant's data? I picked one, wrote it as a constant, and shipped it with a comment explaining that there was deliberately no per-customer configuration. That comment survived about six weeks.
// Minimum role level for analytics access.
// Manager, matching requireTierAtLeast on the
// reporting routes these tools reproduce.
const MIN_TIER_LEVEL = tierLevel[TIER_MANAGER];
const qualified = (await resolveLevels(members))
.filter((entry) => entry.level >= MIN_TIER_LEVEL)
.map((entry) => Number(entry.member.tenantId));
The reasoning was defensible: the reporting screens is the closest thing the product already has to this capability, so let it set the bar. What it missed is that a screen and a connector are not the same object even when they read the same table.
A connector is not a screen: A reports screen shows one filtered view at a time, to someone who navigated to it. A connector hands an AI client every usage row in the tenant in a single call, and the client will happily ask for all of it. Same data, very different blast radius — which is why matching the screen's permission was the wrong default even though it felt conservative.
Two independent grounds, not one threshold
The request that broke the constant was mundane: one analyst on a Lead account needed the connector, and nobody was willing to promote her to get it. A threshold cannot express that. Promote her and she gains every admin screen too; leave the bar where it is and she gets nothing.
So the rule stopped being a threshold and became two independent grounds, either of which is sufficient.
Either check is enough. They are OR-ed, never intersected — that property is the whole design.
The tenant's allowed list contains the member's account type, or the member has been assigned the connector individually. And above both, a per-tenant kill switch that overrides everything.
Writing it as OR rather than AND is the decision that matters, and it is worth being explicit about why. An allow list plus an individual list that *narrowed* it would mean adding one person silently cuts off every owner — a footgun disguised as a feature. Additive means assigning someone can only ever grant, so the destructive action stays where people expect it: unticking a tier.
No per-member denial, on purpose: I left out a deny list. Two lists that can contradict each other need a precedence rule, and precedence rules are where access-control bugs live. If you want someone out, they come off the tier — and because there is no deny row, a member's absence from the assignment table is never a decision about them.
The data model, and why an absent row means something
Two tables. One row per tenant holding the list and the switch, and one row per individually-granted member.
await queryInterface.createTable('connectorAccessSettings', {
tenantId: { type: BIGINT, allowNull: false,
references: { model: 'tenants', key: 'id' },
onDelete: 'CASCADE' },
// Names, not levels: a level threshold cannot express
// "Owner and manager, but not admin".
allowedTiers: { type: JSON, allowNull: false,
defaultValue: DEFAULT_ALLOWED_TIERS },
// Distinct from an empty list, which still lets
// assigned members in.
enabled: { type: BOOLEAN, allowNull: false,
defaultValue: true },
});
// Assigning twice is a no-op, not a second grant.
await queryInterface.addIndex('connectorAccessMembers',
['tenantId', 'memberId'], { unique: true });
Storing names rather than a numeric floor is the non-obvious call, and the diagram two sections down is the reason. Storing a floor would have been smaller and would have quietly made one configuration unrepresentable.
The other thing to get right early: a tenant with **no settings row** is a real state, not a missing one, and it must resolve to the default rather than to "nothing configured, so allow everything". Put that defaulting in one function that both the gate and the settings API call, or the two will disagree within a month.
const defaultSettings = () => ({
allowedTiers: [...DEFAULT_ALLOWED_TIERS],
enabled: true,
configured: false, // so the UI can say "on the default"
});
// Tenants with no row come back carrying the default, not
// absent — an absent key makes every caller re-derive it.
const getSettingsForTenants = async (ids) => {
const out = {};
ids.forEach((id) => { out[id] = defaultSettings(); });
const rows = await AccessSetting.findAll({
where: { tenantId: { [Op.in]: ids } } });
rows.forEach((r) => { out[Number(r.tenantId)] = {
// Normalise on read: a tier retired since the row was
// written must not resolve to a name nothing matches.
allowedTiers: normalize(r.allowedTiers),
enabled: Boolean(r.enabled), configured: true }; });
return out;
};
The trap: exact names for built-in tiers, levels for custom roles
Here is where a dual permission system bites. Like a lot of products mid-migration, ours has both a legacy tier string and a newer roleId pointing at a roles table with a numeric level. There is already a resolver that collapses both into a level, and reaching for it is the obvious move — compare that level against the lowest ticked tier and you are done.
That is wrong, and it is wrong in a way no one notices until a customer configures something unusual.
Tick the top and the third tier but not the second, and a level comparison readmits the tier you unticked.
Tick Owner and Manager but not Admin. A level comparison asks "is your level at least 3?" — and an Admin sits at 4, so it lets them in, past a checkbox the customer deliberately cleared. The checkbox list stops meaning what it says.
So the two systems get matched differently, and the asymmetry is deliberate rather than a leftover.
const allowedByTier = (member, level, allowed) => {
if (!allowed.length) return false;
// A custom role has no account type to match, so it is
// judged by level against the lowest allowed tier.
if (member.roleId) {
const floor = lowestAllowed(allowed);
return floor !== null && Number(level) >= floor;
}
// A built-in tier matches BY NAME. Matching by level
// here would readmit an unticked middle tier.
return allowed.includes(member.tier);
};
Nine lines, and the comments are longer than the code. That ratio is correct here. Anyone reading this later will feel the pull toward collapsing both branches into the level comparison, because it is shorter and the resolver already exists — the comment is there to stop them, and the test in the next section is there for when they ignore it.
Where the default sits
Making the bar configurable does not excuse you from choosing a starting position, and the starting position is the one most customers will keep. I moved it up rather than leaving it where the constant had it.
Configurable, and narrower out of the box. Widening it is now a deliberate act with a name attached.
Owner and Admin only, by default — narrower than the screens this capability reproduces. That looks inconsistent until you remember the blast radius: the safe default for a capability that returns a whole tenant at once is the smallest group that already has that reach, and everyone else arrives through a decision someone made on purpose.
Configuring access has to outrank the access it grants
One rule decides whether this feature is safe: the endpoint that configures the list must outrank every tier that list can hand access to. Gate it where most settings live — one tier down — and a Manager can tick their own tier, which makes the whole control decorative. Gate it above, and widening is never self-service for the tiers below.
// Gated at Admin rather than the Manager
// bar most settings use: this endpoint decides who can read
// every member's usage data through an external client.
router.put('/:tenantId/mcp-access',
requireTierAtLeast(TIER_ADMIN),
async (req, res) => { /* ... */ });
Two smaller decisions that came out of building the screen. Save both halves in one request, because saving them separately can leave a tenant briefly locked out of its own connector. And drop ids that are no longer active members rather than rejecting the whole save — then report what you dropped, so the UI re-renders from the response instead of from what it hoped it saved.
Audit the change, not just the queries: Every tool call was already audited. Widening who may connect is the more interesting event, and it is the one someone will need to attribute six months later. Write the previous and new values into whatever trail your product already has — a new mechanism nobody queries is worse than a row in the table people already know about.
The test suite that mocked away a real bug
I wrote fifteen tests for the gate. They covered the additive assignment, the kill switch overriding it, the unticked middle tier, per-tenant divergence, the superadmin bypass. All fifteen passed. The feature then failed on the first real request.
SequelizeDatabaseError:
Table 'app_dev.connector_access_member' doesn't exist
sql: SELECT `memberId` FROM `connectorAccessMember`
WHERE `tenantId` = 2;
# the migration created `connectorAccessMembers` — plural
The cause is a one-word slip. This codebase sets freezeTableName: true globally, so Sequelize does not pluralise — the name you pass to define() *is* the table name — and the convention here is that the model file is singular while the define name matches the migration. I wrote the file name into the define call.
The interesting part is not the typo, it is why fifteen tests were blind to it. They mocked the ORM module wholesale, which is the right call for testing branch logic — no database, milliseconds to run. It also means the real define() names never execute, so no amount of coverage in that file could ever catch a table-name mismatch.
// Defining a model opens no connection, so this needs no DB.
const created = () => [...fs.readFileSync(MIGRATION, 'utf8')
.matchAll(/createTable\('([^']+)'/g)].map((m) => m[1]);
test('every table the migration creates has a model', () => {
const tables = created();
const models = [Setting.getTableName(),
Member.getTableName()];
expect(models.sort()).toEqual([...tables].sort());
});
Mocks hide whole categories, not individual cases: The lesson is not "mock less". It is that mocking a layer removes every bug that layer can produce, and you should know which category you just excluded. Mock the ORM and you have excluded schema drift — so add one cheap test that reads the migration and asserts against the real model metadata. It runs in under a second and needs no database.
And one that only appears in a browser
The settings screen needed a copy-pastable command block for the docs. I built one, shipped it, and got back a screenshot of near-invisible grey text on a white card. The colour was in an inline style; the shared card class carried bg-white; and the Tailwind config had this at the top.
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
important: true, // every utility compiles with !important
// ...
};
With important: true, bg-white becomes background-color: #fff !important and beats the inline style — the one place most of us assume nothing can outrank. The light foreground I had set inline applied fine, because nothing competed for it, and the result was invisible text plus a copy button that was white-on-white. My first theory was that the arbitrary-value class was not being generated; I proved that wrong by building the stylesheet and finding it. The fix was to stop mixing mechanisms and put every colour in a utility class on a theme token.
Verify styling against the built stylesheet: You can settle "is this class in the bundle" in about a second: run the Tailwind CLI against the real config and grep the output. It beats guessing, and it told me the difference between a specificity problem and a build problem — which are fixed in completely different places.
What I would do again
The shape held up. Reuse the product's own rows for identity and tenancy, put the threshold in the product rather than in a constant, make the individual grant additive so the destructive action stays in one place, and default narrow. The two bugs both came from the same reflex — trusting an abstraction I had not checked at its boundary. The ORM's table naming and the CSS cascade are both places where a reasonable assumption is silently wrong, and both were cheap to verify once I stopped assuming.
References
Takeaways
Authenticating with your product's token is the easy half; the threshold is the part that needs a customer-visible decision. A connector returns a whole tenant in one call, so it deserves a narrower default than the screens it reproduces. Make the individual grant additive. An allow list that narrows means adding one person silently cuts everyone else off. With a dual RBAC model, match built-in tiers by exact name and custom roles by level — a level comparison readmits an unticked middle tier. Gate the configuration endpoint above every tier it can grant, or the tier below it can tick itself in. Mocking the ORM excludes schema drift entirely. Add one test that reads the migration and asserts against real model metadata.
All notes · Shehzad Aslam