Governing API Access
Scoped, owned, auditable machine credentials for external systems that call the RapidValue API.
The problem: machines need governed access too
RapidValue already authenticates humans (a session token in the browser) and the on-premise agent (a keypair). But a governance platform is also called by other machines: a customer's human-resources feed pushing joiner and leaver events, an integration flow reading access requests, a script, a security-monitoring pull. Each of those callers needs a credential to reach the RapidValue API (application programming interface — the programmatic entry point to the platform).
The classic anti-pattern is a static, all-powerful secret pasted into a configuration file: nobody owns it, nobody knows what it may do, it never expires, and it never gets reviewed. API access governance treats that machine credential as a first-class governed object instead — scoped, owned, expiring, revocable, and attributed on every call.
A credential is bound to scopes, not a role
The central rule: an API credential carries an explicit set of scopes, and
its power is exactly those scopes — never a role. A scope is a narrow
permission such as identities:write or access-requests:read. When an
administrator issues a credential, they pick the minimum scopes the external
system actually needs.
- The human-resources system that pushes joiner and leaver events gets
identities:writeonly — not full administrative reach. - The integration flow that reads access requests gets
access-requests:read— nothing more.
This is least privilege by construction. A credential can never exceed its declared scopes, and there is no "admin" shortcut that widens it. Each scope also carries a risk flag (low, medium, high) so the person granting it can see what they are handing out.
| Property | What it means |
|---|---|
scopes |
The exact permissions this credential holds — resource plus read or write |
expires_at |
When the credential stops working (no expiry is discouraged) |
last_used_at |
When it last made a call — the dormancy signal |
status |
active, expired, or revoked; expiry is derived from expires_at, not a stored flag that can go stale |
owner |
The accountable team or person, resolved through the ownership model |
Only scopes with something behind them can be issued
The scope grammar covers more resources than the API currently exposes, and the
issuing surface reflects that rather than papering over it: a scope can only be
granted if there is an endpoint enforcing it. Handing out a scope that
authenticates nowhere would look like access and behave like nothing, and it
would show up in a review as a permission somebody has to reason about. So the
picker offers, and the create call accepts, only the enforced set — today the
seven resource reads plus identities:write and the external-onboarding gate
callback. The rest of the grammar stays declared but un-issuable until an
endpoint lands behind it.
The secret is shown once, then stored only as a hash
When a credential is created or rotated, its secret value is displayed exactly once, in a "copy it now, you won't see this again" moment. RapidValue never stores the secret in plaintext and never returns it again — only a one-way hash is kept, enough to verify a caller but not to reproduce the secret. Verification compares in constant time and hashes even when no such credential exists, so the comparison itself leaks neither the secret nor whether the key id was real.
💡 Tip
Because the secret is shown once, rotation is the safe way to recover from a lost or leaked secret: rotate to mint a new secret, and the old one is invalidated. Revoke instantly if a credential is known to be compromised — authentication stops immediately.
Scope is enforced on every request — out of scope is denied and audited
Every authenticated call is checked against the credential's scopes at request time. A call that falls outside them is refused, and the denial is written to the immutable audit trail, attributed to that credential. Every successful call is likewise attributed there.
Worth being precise about what makes that trustworthy: the denial is recorded before the refusal is raised, and it is recorded on the same code path as a success. There is no way to be refused quietly, because refusing and logging are not two decisions.
// audit — the same integration, two calls, one scope
{ "event_type": "API_CREDENTIAL_CALL", "actor_email": "apicred:apicred-7f3a1c9d",
"payload": { "path": "/api/v1/api-gateway/identities/e-4417",
"scope": "identities:write", "name": "HR feed" } }
{ "event_type": "API_CREDENTIAL_CALL", "actor_email": "apicred:apicred-7f3a1c9d",
"payload": { "path": "/api/v1/api-gateway/entitlements",
"scope": "DENIED:entitlements:read", "name": "HR feed" } }
The actor is the credential itself, not a person, and the readable name travels in the payload — so a review reads "the HR feed tried to list entitlements and was refused", with the credential identifier to act on.
The effect is that an auditor can answer, from the audit trail alone: which external systems can call our platform, with what rights, who owns them, when they were last used — and see proof that a credential was held to least privilege, including the attempts it was denied.
identities:write — the API as a governed identity source
The flagship machine write is an HR feed pushing joiner, mover and leaver events. That is a bigger thing than "a write endpoint", and it is modelled as the bigger thing: an API push is a push-mode sync of one identity, running through the same ingest path a scheduled synchronisation uses. It stamps the same source provenance, resolves and auto-creates the same organisational context objects, and fires the same lifecycle events a pull-based feed would. It is never an ad-hoc database insert with the governance skipped.
That framing is what makes the write legitimate. The identity arrives owned by a
named source (api), not as an unattributable row — and not as manual, which
would claim a human typed it, or as hr, which would corrupt the counts the
leaver safeguards depend on. Downstream access converges through the ordinary
reconciliation machinery afterwards; the push does not get its own provisioning
path.
Leavers must be stated, never inferred. A pull-based synchronisation sees a full snapshot and can conclude that a missing person has left. An event push sees one person at a time and has no snapshot, so absence means nothing. A departure therefore has to be explicit — a terminated status or an end date in the past. Non-human identities are refused outright (they have their own ownership and lifecycle model), and there is no delete: erasure under GDPR stays an administrative flow with its own record, not something an integration key can do.
The conflict rule: the owning source wins, loudly
The dangerous case is an API key writing over a person that HR owns. The rule is that it refuses rather than merges:
// PUT /api/v1/api-gateway/identities/e-4417 → 409
{ "error": "source_conflict",
"owning_source": "hr",
"identity_business_id": "id-0a91c7" }
Nothing is written. The response names the source that won, so the integration
can log something an operator can act on rather than a bare rejection. A second
refusal, ambiguous_match, covers a different failure: the push matched an
identity the API does own, but filed under a different external id — a
key-space clash, which is a data problem to fix rather than a merge to guess at.
Batch: one bad row does not fail the rest
Feeds arrive in batches, and a batch that fails whole on one bad row is a batch that gets retried in a loop. So a batch is up to 500 items, each running the same write as the single push, and each isolated so that one item's rollback cannot discard a sibling's successful write. The response reports per item:
{ "results": [
{ "source_external_id": "e-4417", "status": "created", "identity": { "…": "…" } },
{ "source_external_id": "e-4418", "status": "noop", "changed_fields": [] },
{ "source_external_id": "e-4419", "status": "error",
"code": "source_conflict", "owning_source": "hr" }
]}
Re-running the same batch produces noop throughout, so a retry after a network
failure is safe and does not fill the audit trail with events for changes that
did not happen.
Partial merge: opt-in, and its writable set is not configurable
Sometimes an integration legitimately needs to annotate a person HR owns — attach a badge number, a site code, a system-of-record identifier — without pretending to own them. That is a per-credential opt-in, off by default, and its scope is fixed in code rather than configurable.
What it may write: custom attributes that no authoritative source itself writes, plus a system-stamped key recording the pushing system's own identifier for that person, so the integration can correlate an identity it does not own.
What it may never write, opt-in or not: the entire profile core — names, email, employee id, status, hire and end dates, department, function, cost centre, location, company, manager, identity type. There is no setting that unlocks those. A configurable safe set would eventually be widened by whoever most wanted it widened, at which point the guarantee would be a preference.
It is never silent. A merge that writes some fields and denies others returns both lists, so the caller can see exactly what happened:
{ "result": "updated", "merge": "partial", "owning_source": "hr",
"changed_fields": ["attributes.badge_id", "attributes.api_external_id"],
"skipped_fields": [
{ "field": "department", "reason": "hr_authoritative" },
{ "field": "attributes.cost_center", "reason": "source_owned_attribute" }
] }
If every field in the push is denied, the answer is an honest no-op with every field named — not a success that wrote nothing. And the ambiguous-match refusal is never bypassed by the opt-in: it is a data problem, not a permission one.
The API source is a visible system, not a hidden path
A tenant that accepts identity pushes gets a real, locked connector on the
Systems page — an inventory row called "API identity source", alongside every
other connected system. It is not synthetic bookkeeping: it appears the first
time a credential is issued with identities:write, and heals itself if it is
ever missing when a push arrives.
For an auditor this is the difference between "identities come from HR, Entra ID and a directory" and a fourth source that only exists inside somebody's integration. Concretely it buys four things:
- Inventory. The push source is listed where the pull sources are, with an
owner, rather than being visible only as an unusual
primary_sourcevalue on individual people. - Attribute authority. Like any source, it declares which identity attributes it is authoritative for — and that list is administrator-editable while the technical configuration stays locked. A push that includes a field outside the list is skipped and named back to the caller, exactly as a pull source's non-authoritative field would be dropped.
- Freshness. Every accepted push stamps a "last push" timestamp, so a feed that has quietly stopped is visible on the same surface where a stalled scheduled synchronisation would be.
- Honest absence of a schedule. It is a push source, so it is never polled and has no synchronisation schedule to configure. The interface says so rather than showing an empty schedule that looks broken.
An API credential is an ownable object
A credential is registered as an ownable object, so it carries an owner — the integration team or person accountable for it — assigned through the standard ownership model rather than a free-text field.
Its ownership cascade is deliberately short: an explicit owner, or the terminal bucket. There is no manager or system fallback to invent an owner from, because there is no honest one to infer — a machine credential has no manager. An unowned credential is therefore a visible gap in ownership coverage rather than something quietly attributed to whoever happens to be nearby.
Where the tenant has an ownership attestation review configured, credentials flow through it like any other owned object: periodically the owner confirms the credential is still needed. That is a review the tenant switches on, not something that happens by itself — so the honest statement is that a credential is reviewable on the same machinery as human access, not that it is automatically reviewed.
Hygiene signals: surfaced, never auto-acted
Three signals are computed and raised through the Platform Advisor, the platform's single recommendation inbox:
- Dormant — an active credential unused for an extended period (90 days by default, configurable), or never used at all. A likely candidate to revoke.
- Over-scoped — a credential holding scopes it has never exercised. Those should be trimmed back toward least privilege.
- Expired but still active — past its expiry while still stored as active. Cleanup, not a security event, but it should not sit there.
A fourth watches the blast radius of the identity write specifically: a credential that terminates an unusual number of identities inside a short window is flagged. That is what a compromised or misconfigured HR feed looks like from the outside. It is deliberately a signal, not a block — a legitimate bulk offboarding exists, and auto-revoking the feed on a false positive would be a self-inflicted outage. Its evidence comes from the immutable audit trail, so the count cannot be edited after the fact, and its threshold can be raised per credential for a feed that legitimately moves in bulk.
None of these act on their own. Revoking or re-scoping a credential is an administrator's decision — an integration may legitimately be seasonal — so the advisor surfaces the finding and the human decides.
Usage is tracked as counters rather than raw call rows: total calls, when it was last used, the endpoints it hits most, and the scopes it has never exercised. That is enough to answer "is this still needed, and is it right-sized?" at a glance, without keeping a log of every machine call as governance data.
Known edges, published rather than discovered
- Authentication is a key in a header. A credential presents a single secret string; there is no token-exchange flow behind it. The data model reserves a slot for an OAuth2 client-credentials type, but nothing implements one today, and this page will not describe one until something does.
- Usage counters are cumulative, not windowed. "Calls" means calls since the credential was issued, and the dormancy signal is derived from the last-used timestamp, not from a rolling rate. There is no per-day call history to chart.
- Most write scopes are not issuable yet. Only
identities:writeand the external-onboarding gate callback exist as machine writes. The rest of the write grammar is declared and refused at issue time — see above; that is the design, but it does mean the API is read-heavy today. - A push cannot skip governance, and it does not get a shortcut either. An identity created by a credential is subject to the tenant's ordinary policies, reconciliation and reviews. What the credential controls is the identity record, never the access derived from it.
- No external certification. Nothing here has been certified or attested by a third party. What exists instead is inspectable mechanism: refusals recorded on the same path as successes, a safe set fixed in code rather than in settings, and an immutable audit trail behind every claim on this page.
What this is not
API access governance is deliberately kept separate from two neighbouring concepts:
- The agent's own machine identity — the on-premise agent authenticates with its own keypair. That is the agent proving it is the agent, a different thing from a tenant issuing outbound-integration credentials.
- The vendor's platform-admin key — a platform-internal credential used by RapidValue itself, not a tenant-facing surface. A tenant-issued credential can never carry a vendor scope; the issuing path rejects the whole namespace.
API credentials are the tenant's own governed keys for the external systems that integrate with their platform. They are managed under Environment & connections, the technical tenant-setup surface.