Accounts, Users & Access
Your organization is a tenant on CORE-M — your own isolated slice of the platform. Every request you and your team make carries an identity that belongs to your tenant, and CORE-M answers two questions on each call: who are you (authentication) and are you allowed to do this (authorization). Your tenant’s data is automatically scoped to you and never visible to any other tenant.
This page explains everything you manage for your own tenant: users and the role model, group-based page permissions, the JWT lifecycle, login rate limiting, API keys, SSO configuration, and how isolation keeps your data separate.
Your tenant
Section titled “Your tenant”Your tenant is your top-level isolation boundary. When your tenant is created you get:
- A unique
tenant_idthat scopes every piece of your data. - An initial admin user (role
tenant_admin), with the password stored bcrypt-hashed. - An API key for programmatic access, returned once.
- A default group “All Users” with every page permission
(
dashboard,devices,telemetry,rules,anchors). Your initial admin is not added to it — admins bypass group checks via their role.
Tenant names are unique; a duplicate name is rejected with ALREADY_EXISTS.
Each user in your tenant has exactly one role. Roles define administrative scope and are coarse-grained; fine-grained access for standard users comes from groups.
| Role | Scope | What it grants |
|---|---|---|
platform_admin | System-wide | Reserved for the Kronox Data platform team that operates CORE-M. Not assignable by your tenant. |
tenant_admin | Your tenant | Full access within your tenant: users, groups, devices, rules, API keys, and settings. Bypasses group permissions inside your tenant. |
member | Your tenant | Standard user. Access is controlled entirely by group memberships. No access to /manage/* or /admin/*. |
Groups and page permissions
Section titled “Groups and page permissions”Groups are tenant-scoped collections of users with a defined set of page permissions. A user can belong to multiple groups, and their effective permissions are the deduplicated union of all their groups’ permissions.
Available page permissions:
| Permission | Grants access to |
|---|---|
dashboard | Dashboard overview |
devices | View and manage devices |
telemetry | Telemetry dashboards and charts |
rules | View and manage rules |
anchors | Blockchain proof data on device detail pages |
Permission rules in practice:
- A
memberwith no groups has no access at all and sees: “Your account has no permissions assigned. Contact your administrator.” - A
memberwho tries an action outside their permissions is rejected withPERMISSION_DENIEDand “missing permission: rules” (or the relevant page). - Changing a group’s permissions does not rewrite live tokens. Existing JWTs go stale and pick up the new permissions on the next token refresh.
- Deleting a group removes all its members and recalculates their effective permissions, which may drop access to some pages.
User authentication
Section titled “User authentication”Users authenticate with email and password. Passwords are bcrypt-hashed at rest. Login responses are timing-safe and never reveal whether the email or the password was wrong — a nonexistent user is indistinguishable from a wrong password.
A successful login issues two tokens, both Ed25519-signed:
- An access token, 15-minute expiry, presented on every API call.
- A refresh token, 7-day expiry, used only to mint new access tokens.
The JWT lifecycle
Section titled “The JWT lifecycle”flowchart TD
login([Login: email + password]) -->|valid| issue["Issue access (15 min)<br/>+ refresh (7 day)"]
login -->|invalid| fail["UNAUTHENTICATED<br/>(timing-safe, no field hint)"]
issue --> use["Call API with access token"]
use -->|access valid| ok([Request authorized])
use -->|access expired| refresh{Refresh token<br/>still valid?}
refresh -->|yes| rotate["Issue new access token<br/>+ rotate refresh token<br/>(old refresh invalidated)"]
rotate --> use
refresh -->|no| relogin([Redirect to login])
Refresh is rotating: each RefreshToken call invalidates the old refresh token
and issues a fresh one alongside the new access token. An expired refresh token is
rejected with UNAUTHENTICATED, forcing a fresh login.
The access token is signed with Ed25519 using the key from the AUTH_SIGNING_KEY
environment variable. Downstream services verify the signature and read the claims —
they do not call back to the auth service per request.
Example JWT claim set
Section titled “Example JWT claim set”A member in tenant T1, in groups “Engineering” (devices, telemetry) and
“Monitoring” (dashboard, rules), receives an access token whose payload looks
like this:
{ "sub": "9f2c1e44-1b3a-4c8e-9d77-6a0b2f5e1c90", "tid": "1a2b3c4d-0000-4e5f-8a9b-112233445566", "role": "member", "groups": [ "grp-engineering-001", "grp-monitoring-002" ], "permissions": [ "dashboard", "devices", "rules", "telemetry" ], "iat": 1747396800, "exp": 1747397700}| Claim | Meaning |
|---|---|
sub | User ID |
tid | Tenant ID — the isolation key for all downstream queries |
role | One of platform_admin, tenant_admin, member |
groups | Group IDs the user belongs to |
permissions | Deduplicated union of all group page permissions |
iat / exp | Issued-at and expiry (15-minute window for access tokens) |
Login rate limiting
Section titled “Login rate limiting”To blunt credential-stuffing and brute force, failed logins are rate-limited on two independent dimensions, both with a 900-second (15-minute) TTL:
| Dimension | Threshold | Aerospike key | TTL |
|---|---|---|---|
| Per email | 5 failed attempts in 15 min → block the 6th | loginrl:{email_hash} | 900s |
| Per source IP | 20 failed attempts in 15 min → block the 21st | loginrl:ip:{ip} | 900s |
A rate-limited request is rejected with RESOURCE_EXHAUSTED and “too many login
attempts, try again later”. The response does not reveal whether the account exists,
and corem_auth_login_rate_limited_total is incremented. Because the lockout state
lives in an Aerospike key with a TTL, the limit clears itself once the window
expires — there is no manual unlock step.
API keys
Section titled “API keys”API keys authenticate device-to-platform and programmatic calls into your tenant.
- Format: prefix
sk_live_followed by 32 cryptographically random bytes, URL-safe encoded. - Storage: only the hash is stored — never the raw key.
- Display: the raw key is returned exactly once at creation. There is no way to recover it later; lose it and you rotate.
Presenting a key as Authorization: Bearer sk_live_… lets CORE-M resolve your
tenant_id (and, for device keys, the device_id) and scope the request to your
tenant. A revoked key is rejected with UNAUTHENTICATED.
SSO configuration (config-only in v1)
Section titled “SSO configuration (config-only in v1)”CORE-M stores and validates SSO provider configuration for your tenant for SAML 2.0 and OIDC.
| Field | SAML 2.0 | OIDC |
|---|---|---|
| Protocol | saml | oidc |
| IdP metadata / discovery | Metadata XML URL or uploaded XML | Discovery URL (.well-known/openid-configuration) |
| Entity ID / Client ID | SP Entity ID | Client ID |
| Client Secret | N/A | Client Secret (encrypted at rest) |
| Enabled | Whether the stored config is enabled for future integration | Same |
A configuration test checks that the IdP metadata or OIDC discovery endpoint is reachable and valid and reports discovered attributes. The test never creates or updates CORE-M users.
How your data stays isolated
Section titled “How your data stays isolated”Isolation is enforced on every request, not as a feature of any one screen:
-
A request arrives with a JWT or API key.
-
CORE-M extracts your
tenant_id(from thetidclaim or the API key) and attaches it to the request. -
Every read and write is scoped to that
tenant_id, so a request made under your tenant can never address another tenant’s data. -
Any cross-tenant access is rejected with
PERMISSION_DENIEDand logged for audit.
This is why the tid claim matters so much: it is the single value that pins an
entire request to your tenant’s slice of the platform.