Developer Hub › Configuration, Roles & Policy

Configuration, Roles & Policy

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.

One mechanism: the four-scope config plane

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)
This is exactly the "global for all my apps, or per app" model, and it needs no new tables. Set 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.

Setting and reading

CallWhoPurpose
POST /api/configtenant admin (JWT)Set a value at a chosen scope.
GET /api/config/resolvethe tenant's appResolve a key for a context — this is what your app calls at runtime.
GET /api/config/valuetenant adminRead one exact row (no chain walk) — for showing what is set here.
POST /api/config/rotatetenant adminRotate the secret behind secret_ref.
POST /api/config/revoketenant adminMark revoked; resolution then falls through to the next scope.
Never put a credential in 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.

Worked example — an email provider for every app, except one

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.


Roles — RBAC

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_idMeaningUnique on
NULLPlatform default for an app — the standard roles you ship.(app_id, name)
setTenant 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

Policy — ABAC

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.

Which of the three to reach for

LayerAnswersUse when
RBAC · role templatesWhat may this role do?Permissions are identical for everyone holding the role.
ReBAC · sdk-rebacMay this persona act on that record?Authority comes from a relationship — owner, delegate, account team.
ABAC · sdk-policyDo 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.


Tenant portal — what exists and what does not

Every API above is live. The portal reaches only some of them today.

ConcernAPIapps/tenant-admin UI
Applications & API keysliveapplications/, api-keys/
Memberslivemembers/
Connectors · Notifications · AI · BYOK · Billing · Consent · Webhooks · Approvalslivepresent
Configurationliveconfig/ + lib/scopedConfig.ts — resolves each key and reports which scope answered
Role templatesliveroles/ — inherited defaults shown as inherited; override is explicit
Policieslivepolicies/ — with an evaluate-before-save dry run
The screens now exist. Roles and Policies have pages, and configuration resolves each key through 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.

Still open: these pages are written but not yet exercised against a running portal.

Three gaps to design around today

The APIs above are all live. These three limits are in the current build, and each one changes what you should promise a tenant.

1 · App-scope config is now editable in the portal; app_user scope is still API-only. The Configuration page carries a scope switcher — "All apps (tenant default)" plus one entry per application — and the chosen scope lives in the URL (/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.
2 · Policies can now vary per app. policy.policy carries an app_id, scoped the same way tenant.role_template always was:
tenant_idapp_idMeans
NULLNULLplatform default — applies everywhere
setNULLtenant-wide — every app of that tenant (the previous behaviour)
setsetthat 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.

3 · Not every provider key is wired to a consumer. Setting a key that nothing reads looks identical to setting one that works — the row saves, the page shows it, and the feature stays dead. Only these keys are resolved by runtime code today, via checkProviderConfigured():
KeyConsumed byStatus
llm.providersdk-ai-gatewaylive
payment.providersdk-paymentlive
notification.email.credentialsdk-notificationlive
search.providerno consumer yet
media.s3no consumer yet
The key names are now consistent — the portal cards and the provider descriptors both use the runtime keys (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.

What that UI needs to get right