How a tenant configures providers, roles and access rules once — globally across all their apps, or overridden per app — and how their apps read those values at runtime.
Every tenant-settable value — an email provider, a storage bucket, a payment key, a feature threshold — lives in one table with one resolution rule.
config.config_value
scope 'platform' | 'tenant' | 'app' | 'app_user'
scope_id '' | tenant_id | app_id | app_user_id
key 'email.provider'
value JSONB ← non-secret settings
secret_ref TEXT ← pointer into the sdk-secrets envelope, for credentials
UNIQUE (scope, scope_id, key)
resolveConfig(key, ctx) returns the most specific active row:
app_user → app → tenant → platform (first match wins)
email.provider at tenant scope and every one of
that tenant's apps inherits it. Set the same key at app scope and that one app
overrides it while the others keep inheriting. Delete the app row and it falls back — nothing
to clean up, because the fallback is the absence of an override rather than a copied value.
| Call | Who | Purpose |
|---|---|---|
POST /api/config | tenant admin (JWT) | Set a value at a chosen scope. |
GET /api/config/resolve | the tenant's app | Resolve a key for a context — this is what your app calls at runtime. |
GET /api/config/value | tenant admin | Read one exact row (no chain walk) — for showing what is set here. |
POST /api/config/rotate | tenant admin | Rotate the secret behind secret_ref. |
POST /api/config/revoke | tenant admin | Mark revoked; resolution then falls through to the next scope. |
value. value is plain JSONB
and is returned by reads. Secrets belong in secret_ref, which points at the
sdk-secrets envelope — that is why the column exists, and why rotation is a separate call
rather than an update to the row.
email.provider below is illustrative — it keeps the example
readable. The key sdk-notification actually resolves is
notification.email.credential; see the wired-keys table further down before
copying a key name into a real call.
# 1 · tenant-wide default: every LeadFlow app sends through SendGrid
POST /api/config
{ "scope":"tenant", "scope_id":"<tenant_id>", "key":"email.provider",
"value": { "driver":"sendgrid", "from":"no-reply@leadflow.io" },
"secret_ref": "secret://tenant/<tenant_id>/sendgrid-api-key" }
# 2 · one app overrides it (transactional mail through SES)
POST /api/config
{ "scope":"app", "scope_id":"leadflow-mobile", "key":"email.provider",
"value": { "driver":"ses", "region":"us-east-1", "from":"alerts@leadflow.io" },
"secret_ref": "secret://app/leadflow-mobile/ses-key" }
# 3 · the app just asks — it does not know or care which scope answered
GET /api/config/resolve?key=email.provider&tenant_id=…&app_id=leadflow-mobile
→ { scope:"app", value:{ driver:"ses", … } } ← the override
GET /api/config/resolve?key=email.provider&tenant_id=…&app_id=leadflow-web
→ { scope:"tenant", value:{ driver:"sendgrid", … } } ← inherited
The same shape covers cloud region, object storage, payment provider, AI provider and any tenant-tunable threshold. Adding a new configurable is adding a key, not a migration.
tenant.role_template is keyed (tenant_id, app_id, name) with
parent_role_template_id for inheritance and a JSONB permissions map.
The nullable tenant_id is the whole trick:
tenant_id | Meaning | Unique on |
|---|---|---|
NULL | Platform default for an app — the standard roles you ship. | (app_id, name) |
| set | Tenant override of that same role name. | (tenant_id, app_id, name) |
So a tenant can redefine what "Manager" means in your app without forking it, and a tenant that never touches it keeps inheriting your definition.
POST /api/role-templates # define/override a role for an app
POST /api/role-assignments # assign it
POST /api/role-assignments {persona_id, role_template_id} # grant beyond the starting template
GET /api/personas/{persona_id}/roles # LIST a persona's grants (read-only)
POST /api/role-assignments/{id}/revoke
POST /api/policies # create a rule
GET /api/policies/{policy_id}
POST /api/policies/evaluate # DRY RUN — permit/deny for a hypothetical context
/api/policies/evaluate matters more than it looks. A policy engine whose decisions
cannot be previewed gets switched off the first time it denies something important, because
nobody can tell a correct denial from a misconfigured one. Evaluate before you save.
| Layer | Answers | Use when |
|---|---|---|
| RBAC · role templates | What may this role do? | Permissions are identical for everyone holding the role. |
ReBAC · sdk-rebac | May this persona act on that record? | Authority comes from a relationship — owner, delegate, account team. |
ABAC · sdk-policy | Do the attributes permit it right now? | The decision turns on region, consent, time or record state, not identity. |
They compose in that order. A human JWT carries no scopes, so a revoked grant takes effect on the next request rather than at token expiry.
Every API above is live. The portal reaches only some of them today.
| Concern | API | apps/tenant-admin UI |
|---|---|---|
| Applications & API keys | live | applications/, api-keys/ |
| Members | live | members/ |
| Connectors · Notifications · AI · BYOK · Billing · Consent · Webhooks · Approvals | live | present |
| Configuration | live | config/ + lib/scopedConfig.ts — resolves each key and reports which scope answered |
| Role templates | live | roles/ — inherited defaults shown as inherited; override is explicit |
| Policies | live | policies/ — with an evaluate-before-save dry run |
lib/scopedConfig.ts so every row reports the scope that
answered — inherited from tenant, overridden here, or platform default. Provider setup is driven
by lib/providerDescriptors.ts, where each field declares whether it is a secret and
splitSecretFields() routes those to secret_ref so a credential can
never land in config_value.value — which is plain JSONB returned by ordinary reads.
That rule is enforced by a test that asserts it for EVERY descriptor, so a provider added later
cannot quietly regress it.
The APIs above are all live. These three limits are in the current build, and each one changes what you should promise a tenant.
/config?app=<application_id>)
rather than in client state, so it survives a reload and is visible before you write to it. Each
card still reports which scope answered, so an inherited value is distinguishable from one set
here. app_user scope remains API-only (POST /api/config with
scope:"app_user"); assertWritable permits it for the user themselves or
a tenant admin.
policy.policy carries an
app_id, scoped the same way tenant.role_template always was:
tenant_id | app_id | Means |
|---|---|---|
| NULL | NULL | platform default — applies everywhere |
| set | NULL | tenant-wide — every app of that tenant (the previous behaviour) |
| set | set | that one app only |
POST /api/policies { "name":"pii-read", "version":"1.0.0", "app_id":"leadflow-web", … }
GET /api/policies?app_id=leadflow-web → the app's rules FIRST, then the tenant-wide ones it inherits
GET /api/policies → tenant-wide only
Existing rows keep app_id NULL and stay tenant-wide — stamping them with an app
would silently narrow live access rules, which is the one migration outcome nobody can review
after the fact.
Listing is not "first match wins", and the difference matters. A config key has one value, so the most specific scope answers and the rest are ignored. Access rules compose: a tenant-wide rule and an app rule are both in force. The ordering exists so an override is presented before the rule it narrows — not so a caller may drop the remainder. Dropping inherited rules because a more specific one exists would widen access at precisely the moment somebody added a restriction.
checkProviderConfigured():
| Key | Consumed by | Status |
|---|---|---|
llm.provider | sdk-ai-gateway | live |
payment.provider | sdk-payment | live |
notification.email.credential | sdk-notification | live |
search.provider | — | no consumer yet |
media.s3 | — | no consumer yet |
llm.provider, payment.provider,
notification.email.credential, media.s3); the portal previously wrote
aws.s3, which matched neither the platform default nor any consumer. The
naming is fixed; the missing consumers are not. Storage and search settings now land on
the right key and will take effect the moment an SDK resolves them, but until then filling those
cards changes no behaviour. Treat them as staged configuration, not as working features.
secret_ref automatically so a
secret cannot be pasted into value by accident./api/policies/evaluate.docs/CONSUMPTION-CONTRACT.md — which SDK owns which table