Atom

Credentials

Managing password, API key, scoped access-token, and certificate credentials for entities.

Credentials are attached to entities. An entity may have multiple credentials of different kinds.

Supported credential kinds:

  • password;
  • access_token (API keys and scoped access tokens);
  • shared_key;
  • certificate.

This page covers passwords, API keys, and scoped access tokens. See Certificates for mTLS certificate credentials, CA files, CRL, OCSP, and runtime lookup.

Password credentials

Used for the /auth/login endpoint. The password is hashed with argon2 before storage; the plaintext is never persisted.

curl http://localhost:8080/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation($entityId: ID!, $password: String!) { createPassword(entityId: $entityId, password: $password) }","variables":{"entityId":"...","password":"s3cur3-p@ssw0rd"}}'

Login returns a JWT and session reference:

curl -X POST http://localhost:8080/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"identifier": "alice@example.com", "secret": "s3cur3-p@ssw0rd"}'
{
  "token":      "eyJ...",
  "entity_id":  "...",
  "session_id": "...",
  "expires_at": "2025-01-01T01:00:00Z"
}

If ATOM_SELF_REGISTRATION_ENABLED=true, a human can self-register into an existing global account without selecting a tenant. Password signup stores the normalized email as the login identifier and requires email verification before normal login:

curl -X POST http://localhost:8080/auth/signup \
  -H 'Content-Type: application/json' \
  -d '{"name": "Alice", "email": "alice@example.com", "password": "s3cur3-p@ssw0rd"}'

Verification and resend endpoints are public:

curl 'http://localhost:8080/auth/email/verify?token=atomv_...'
 
curl -X POST http://localhost:8080/auth/email/resend \
  -H 'Content-Type: application/json' \
  -d '{"email": "alice@example.com"}'

ATOM_ALLOW_UNVERIFIED_EMAIL_LOGIN=true allows local development login before email verification, but inactive and suspended entities are still rejected.

API keys

API keys are long-lived credentials suited for device and service authentication where interactive login is not practical.

Atom stores API keys and scoped access tokens as access_token credentials. The product difference is scope:

Product labelCreatorAuthority
API keyCredential administratorUnscoped; authenticates as the entity with its live grants
Scoped access tokenToken ownerCapped by a permission ceiling and can never exceed the owner's live grants

Format

atom_<32-hex-credential-id>_<64-hex-secret>

The credential ID is embedded in the key for O(1) lookup. The server decodes the ID from the prefix, fetches that specific credential row, then verifies the argon2-hashed secret.

Creating an API key

The dedicated createApiKey mutation has been removed. All tokens are minted through a single surface — createAccessToken. To create an unscoped API key (the owner's full live grants, no ceiling), pass scoped: false with an empty permissions list; omit scoped (or set it true) for a ceiling-capped scoped token.

An unscoped token carries the owner's full authority, so minting one requires credential-management authority over the owner — the same gate as any other credential operation. For provisioned service access, an administrator mints an unscoped token for the service entity (delegated createAccessToken with subjectId + scoped: false):

{
  "input": {
    "name": "fluxmq-auth",
    "subjectId": "<service-entity-id>",
    "scoped": false,
    "permissions": []
  }
}

The full token is returned exactly once and cannot be recovered. Prefer a scoped token (a permission ceiling) whenever the consumer does not need owner-wide surfaces such as authorizedObjectIds, which scoped tokens cannot call.

Using an API key

Pass it as a Bearer token — the service detects the atom_ prefix and routes to API key verification:

curl http://localhost:8080/graphql \
  -H "Authorization: Bearer atom_<id>_<secret>" \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation($input: AuthzCheckInput!) { authzCheck(input: $input) { allowed reason } }","variables":{"input":{"subjectId":"...","objectKind":"resource","objectId":"...","action":"read"}}}'

API key authentication does not create a session and does not return a JWT.

Scoped access tokens

A scoped access token is a least-privilege bearer credential for CLI, API, and automation use. It uses the same atom_<id>_<secret> format as any other access_token credential, but the credential row is marked scoped and carries a permission ceiling.

Effective access

owner live grants  ∩  access-token ceiling

The ceiling is an allow-list that can only narrow the owner. Consequences:

  • A token can never grant anything the owner does not currently hold. Grant the owner a role after minting and the token still only sees what its ceiling allows.
  • Lose the owner's role or Direct Policy and the token loses that access on the next request — state is re-evaluated live, never baked into the token.
  • An empty ceiling is closed: it permits nothing (it is never read as "all"). Revoking, expiring, or emptying the ceiling denies the next request.
  • An owner-level deny (role block or Direct Policy) still overrides any ceiling allow.

Creating a scoped access token

Self-service — the signed-in entity mints a token for itself:

mutation CreateAccessToken($input: CreateAccessTokenInput!) {
  createAccessToken(input: $input) {
    credentialId
    token
    name
    expiresAt
  }
}

Read-only over all resources:

{
  "input": {
    "name": "laptop CLI",
    "description": "Local automation token",
    "permissions": [
      { "actions": ["read"], "scopeMode": "object_kind", "objectKind": "resource" }
    ]
  }
}

The full token is returned exactly once at creation. It is never stored in plaintext and cannot be recovered — store it immediately.

At least one permission is required; an empty permissions list is rejected.

Delegated minting (for services)

Pass subjectId to mint a token owned by another subject — the provisioning path for services and workloads:

{
  "input": {
    "name": "ingest-svc token",
    "subjectId": "<service-entity-id>",
    "permissions": [
      { "actions": ["read"], "scopeMode": "object", "objectId": "<channel-id>" }
    ]
  }
}

Rules:

  • Omit subjectId, or set it to yourself, for self-service.
  • A different subjectId is a delegated mint and requires an unscoped caller holding manage on the target subject (or its tenant) — the same gate as any credential-management operation. A scoped token can never mint delegated tokens.
  • The ceiling is not validated against the target's grants at mint time. Effective access stays target live grants ∩ ceiling, evaluated per request, so a delegated token can never exceed the target even if the ceiling names more.

Permission scope modes

Each ceiling entry is one allow-list row: one or more actions, a scopeMode, its matching scope reference, and optional conditions.

scopeModeRequired fieldsOptionalMatches
platformevery platform-scoped object
tenanttenantIdone tenant
object_kindobjectKindtenantIdall objects of a kind (optionally within one tenant)
object_typeobjectKind, objectTypetenantIdone namespaced sub-type (optionally within one tenant)
objectobjectIdone exact object

objectType must be the full namespaced value (entity:device, resource:channel) matching objectKind — a bare sub-kind (device) is rejected. When tenantId is set on an object_kind/object_type entry, matches are confined to that tenant; omit it for a tenant-agnostic ceiling.

Examples:

{ "actions": ["read", "manage"], "scopeMode": "tenant", "tenantId": "<tenant-id>" }
{ "actions": ["read"], "scopeMode": "object_type",
  "objectKind": "entity", "objectType": "entity:device", "tenantId": "<tenant-id>" }

Conditional (ABAC) entries

An entry may carry conditions evaluated against request context, exactly like a permission block:

{ "actions": ["read"], "scopeMode": "object_kind", "objectKind": "resource",
  "conditions": { "context.region": "eu" } }

Conditions are applied where a full object decision exists (authzCheck, object reads). Coarse control-plane gates run without object context and fail closed on conditional entries — a conditional ceiling entry never satisfies a coarse gate, so a token whose only matching allow is conditional cannot pass an administrative precondition. Use an unconditional entry when a coarse gate must pass.

Using a scoped access token

Pass it as a Bearer token — the atom_ prefix routes to credential verification:

curl http://localhost:8080/graphql \
  -H "Authorization: Bearer atom_<id>_<secret>" \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation($input: AuthzCheckInput!) { authzCheck(input: $input) { allowed reason } }","variables":{"input":{"subjectId":"...","objectKind":"resource","objectId":"...","action":"read"}}}'

Authorization semantics:

  • authzCheck, authzBulkCheck (per-object), gRPC AuthzService.Check, object reads, and authzExplain apply the ceiling when the checked subject is the token owner. When the owner is allowed but the ceiling omits the action or scope, authzExplain returns denied by access token permission ceiling.
  • A delegated check about a different subject is not altered by the caller's ceiling. The caller's right to ask is still capped by its own ceiling.
  • Broad authorized-listing surfaces are not yet ceiling-aware and fail closed for scoped tokens (a bad_request). Use per-object authzCheck or direct object reads.

Listing, replacing, and revoking

query AccessTokens {
  accessTokens {
    items {
      credentialId
      name
      description
      identifier
      status
      scoped
      permissions {
        actions
        scopeMode
        tenantId
        objectKind
        objectType
        objectId
        conditions
      }
      expiresAt
      createdAt
    }
    total
  }
}

Replace the entire ceiling (owner-only; requires at least one permission):

mutation ReplaceAccessTokenPermissions(
  $credentialId: ID!
  $permissions: [AccessTokenPermissionInput!]!
) {
  replaceAccessTokenPermissions(credentialId: $credentialId, permissions: $permissions)
}
mutation RevokeAccessToken($credentialId: ID!) {
  revokeAccessToken(credentialId: $credentialId)
}

accessTokens, replaceAccessTokenPermissions, and revokeAccessToken are owner-scoped — they act only on the caller's own tokens. To manage a delegated token you minted for another subject, revoke it as an administrator with revokeCredential (manage on the target).

Self-escalation guardrails

A scoped token must never widen itself or create a broader sibling. These operations require an unscoped session or credential — the token itself is refused even when its ceiling would grant the underlying capability:

  • create a scoped access token (self or delegated);
  • replace an access token's permissions;
  • revoke an access token or any credential (revokeCredential);
  • create, rotate, reveal, or revoke credentials through credential-management APIs.

Audit

Access-token lifecycle changes are recorded as credential events:

OperationEvent
create (self or delegated)credential.create
replace permissionscredential.update
revokecredential.revoke

Delegated creates record the target as entity_id and set delegated: true in the audit detail.

Listing credentials

curl http://localhost:8080/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"query":"query($entityId: ID!) { credentials(entityId: $entityId) { items { id entityId kind identifier status expiresAt createdAt } total } }","variables":{"entityId":"..."}}'

Returns metadata only — no secret hashes, no plaintext secrets.

{
  "items": [
    {
      "id":         "...",
      "kind":       "access_token",
      "identifier": "atom_abc12...",
      "status":     "active",
      "expiresAt":  null,
      "createdAt":  "2025-01-01T00:00:00Z"
    }
  ]
}

Revoking a credential

curl http://localhost:8080/graphql \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation($entityId: ID!, $credentialId: ID!) { revokeCredential(entityId: $entityId, credentialId: $credentialId) }","variables":{"entityId":"...","credentialId":"..."}}'

The credential status is set to revoked. Revoked credentials are rejected immediately on the next request — no token reissuance is required because API keys are verified live against the database on each request.