Groundfloor Docs

Workspace sign-in API

Configure workspace site auth — mode, end users, app roles, SSO, and branding.

These endpoints manage app end-user identity for a workspace. They do not manage platform membership — use the Memberships API for portal roles.

See also: Authentication

Auth config

MethodPathPermission
GET/v1/workspaces/{workspace_id}/authread
PUT/v1/workspaces/{workspace_id}/authadminister

PUT body (partial): mode (none|groundfloor|external), external, groundfloor, multi_tenant ({ "enabled": true }), host (including custom_domain), branding, mail.

multi_tenant.enabled requires Groundfloor login active (else 409). It turns on tenants for the workspace's sign-in, and adds the organization claim to app users' tokens. Turning it off leaves tenant records until the realm is deleted. v1 is identity only — Access, Vault, Files, and M2M stay workspace-wide.

Leaving groundfloor for none or external disables Groundfloor sign-in and sets auth.groundfloor.status to disabled. It does not delete users. GET still returns the kept groundfloor blob (realm/issuer) until you delete it.

DELETE /v1/workspaces/{workspace_id}/auth/groundfloor

Permission: administer

{ "confirm_name": "Workspace Name" }

Login must already be off (mode is not groundfloor) or the API returns 409. confirm_name must match the workspace name (trim, case-insensitive) or 400. Deletes Groundfloor sign-in (already-deleted is treated as already gone), then clears groundfloor, branding, mail, and SSO connections. Access grants and M2M clients are unchanged.

Sign-in email

auth.mail decides who sends your app users' invitations, password resets and verification emails. GET always returns mail.mode:

  • groundfloor (default): Groundfloor sends them. mail.from_email and mail.from_display_name show the sender (a Groundfloor no-reply address and your organization's name) and are read-only.
  • custom: your own mail server. GET never returns the password; password_set tells you whether one is saved.
{ "mail": { "mode": "groundfloor" } }
{
  "mail": {
    "mode": "custom",
    "from_display_name": "Acme",
    "from_email": "no-reply@acme.example",
    "host": "smtp.acme.example",
    "port": 587,
    "username": "no-reply@acme.example",
    "password": "••••••••",
    "ssl": false,
    "starttls": true
  }
}

With custom, host, port and from_email are required, and port must be 25, 465, 587 or 2525 (else 422). The host must be a public mail server. custom replaces the whole stored block. If you leave out password, the saved one is kept only for the same host and port (and only if you don't turn off SSL/STARTTLS); otherwise send it again.

A mail block with no host and no mode means groundfloor: it switches the workspace to Groundfloor email and deletes the stored custom server settings and password. Switching to groundfloor does the same.

If your own server fails, the email isn't sent; Groundfloor email is never used as a fallback.

Send a test email

POST /v1/workspaces/{workspace_id}/auth/mail/test

Permission: administer

Sends a short test message to your own account email; there's no recipient field. Your email must be verified (else 400). With no body it tests the saved settings; to test unsaved settings first, send { "mail": { …custom block… } }. Returns { "ok", "mode", "sent_to", "error" }: ok: false comes with a short error reason. Limited to a few tests per minute per user (429).

GET also returns cname_target when a custom domain (or branded host) is configured — use it for the DNS CNAME. After DNS is live, Groundfloor issues TLS automatically.

Verify a custom sign-in domain

A custom domain set in host.custom_domain stays pending, and is ignored when sign-in hosts are resolved, until you prove you own it.

MethodPathPermission
POST/v1/workspaces/{workspace_id}/auth/host/verificationadminister
POST/v1/workspaces/{workspace_id}/auth/host/verification/checkadminister

The first call returns the auth config; host.custom_domain_verification holds record_name (_gf-verify.<your domain>), record_value (a gf-verify=… token) for the TXT record, and cname_target. Publish them, then call …/verification/check: it re-resolves DNS and sets host.custom_domain_status to verified on success. An unverified claim is cleared after 14 days; calling the first route again restarts that clock and issues a new token.

Permission catalog

GET /v1/workspaces/{workspace_id}/auth/permissions

Permission: read

Catalog ids (for example vault:read, vault:write) attach to app roles and appear on workspace JWTs. For Data Vault, they do not replace per-collection ACL — see Data Vault API → Collection ACL.

App users

MethodPathPermission
GET / POST/v1/workspaces/{workspace_id}/usersread / administer
POST…/users/addadminister
GET / PATCH / DELETE…/users/{user_id}read / administer
POST…/users/{user_id}/reset-passwordadminister (202)
POST…/users/{user_id}/resend-inviteadminister
POST…/users/{user_id}/temporary-passwordadminister
POST / DELETE…/users/{user_id}/invite-linkadminister

M2M tokens and app users' own tokens get 403 on resend-invite, temporary-password and invite-link. List query: first, limit.

User fields

Besides id (the user's sub), email, names, enabled, email_verified and roles, each user has:

FieldMeaning
invite_pendingtrue until the user has a password
email_sentOutcome of the latest invitation, resend, add or reset email: true sent, false not sent, null unknown (see below)
email_skipped_reasonWhy it wasn't (verifiably) sent, or null
email_attempted_atWhen that email was attempted. null for users invited before delivery was recorded
password_change_requiredtrue when the user must choose a new password at the next sign-in, for example after a temporary password
temporary_password_set_atWhen you last set a temporary password. Temporary passwords don't expire; use this to see their age
invite_link_expires_atWhen the user's unused invite link expires, or null

email_skipped_reason is one of:

ValueMeaning
smtp_not_configuredNo email server is set up for the workspace's sign-in email
send_failedThe email server refused or failed
delivery_unconfirmedThe email server didn't answer in time. The email may still arrive; email_sent is null
not_requestedYou asked for no email (send_email: false)

The older value keycloak_smtp_missing is no longer returned.

Invite (POST /users)

Body: email, optional role, first_name, last_name, send_email (default true), tenant_id (when Multi-tenant is on) and app_id.

Creates a workspace-site user and sends a set-password email (see Sign-in email). The link in it works for 72 hours. Role is an app realm role (viewer, user, admin, or custom) — not a platform membership role. app_id makes the email's sign-in link open that app: a Shell app opens at /apps/{slug} on your Shell site, a standalone app at its own address. It must be a non-archived Shell or standalone app of the workspace (404 if not found, 422 otherwise).

StatusWhen
201The user, with the email outcome fields above
409The person can already sign in: they have a password, another credential or a single sign-on link. Nothing is sent; use reset-password
429Inviting a user who is still pending counts as a resend, and its limits apply

Add (POST /users/add)

POST /v1/workspaces/{workspace_id}/users/add
{
  "email": "user@example.com",
  "generate_password": true,
  "role": "user",
  "first_name": "Ada",
  "last_name": "Lovelace"
}
FieldRequiredDescription
emailYesThe user's email
generate_passwordOne oftrue: Groundfloor generates a temporary password and returns it once
passwordOne ofA password you choose, 8 to 128 characters
temporary_passwordNoWith password: default true, force a change at first sign-in. A generated password is always temporary
roleNoRealm role to assign
app_idNoApp the sign-in link opens, as for invite
first_name / last_nameNoProfile fields

Send exactly one of password and generate_password: true (422 otherwise). The password is set straight away and the email is marked verified. Groundfloor still sends the user an invitation-detail email (sign-in address and role, never the password); there is no send_email option on this route.

Returns 201 with the user, the email outcome fields, sign_in_url (where the user signs in) and temporary_password. temporary_password is the generated password, returned only this once with Cache-Control: no-store, and null when you sent password. It isn't stored or emailed.

Adding an email that belongs to a user who is still pending sets the password on that same user. 409 when the email already has a way to sign in; 422 when the workspace's password policy refuses the password; 504 when setting a generated password timed out (it may or may not be set; retry with temporary-password).

Resend the invitation

POST /v1/workspaces/{workspace_id}/users/{user_id}/resend-invite

Sends the set-password email again, valid for 72 hours, to a user who has no way to sign in yet. Returns 200 with email_sent and email_skipped_reason.

StatusWhen
404Unknown user
409The user can already sign in (use reset-password), or has no email address
429Resent within the last minute, or more than 5 times in 24 hours (counted from the first resend). Re-inviting a pending user counts too

Set a temporary password

POST /v1/workspaces/{workspace_id}/users/{user_id}/temporary-password

Generates a temporary password for a user who is still pending, or who still hasn't used a temporary password you set. Returns 200 with the user, sign_in_url and temporary_password, once, with Cache-Control: no-store. The user must change it at first sign-in. No email is sent; share it privately. The user keeps the same ID and roles, and any unused invite link is cancelled.

409 for a user who chose their own password, has another credential or signs in with single sign-on (use reset-password). 504 when it timed out: the password may or may not be set, so retry.

POST   /v1/workspaces/{workspace_id}/users/{user_id}/invite-link
DELETE /v1/workspaces/{workspace_id}/users/{user_id}/invite-link

POST makes a one-time link with which a pending user chooses their own password. Returns 201 with url and expires_at (24 hours), once, with Cache-Control: no-store: only a hash of the link is kept, so it can't be fetched again. The url opens the Client Portal's /join page; the secret part is after #, so it isn't sent to a server or kept in logs. Every POST cancels the user's previous unused link. Anyone with the link can set the user's password, so share it privately.

StatusWhen
404Unknown user
409The user can already sign in, or is disabled
429More than 10 links for the same user in an hour

DELETE cancels the user's unused link. Returns 204, also when there was none.

The /join page uses two public routes that need no token. Each takes the token from the link (the part after #):

MethodPathBodyReturns
POST/v1/public/invite-links/inspect{ "token" }workspace_name, logo_url, email_masked (a***@example.com) and expires_at. Doesn't use up the link
POST/v1/public/invite-links/redeem{ "token", "password" }sign_in_url. Sets the password and uses up the link

Both return 410 for any link that can't be used: unknown, expired, used, cancelled, or the workspace or user can't take it now. Redeem returns 422 when the workspace's password policy refuses the password (the link still works, so try another), 429 after 5 failed attempts with the same link within 10 minutes, and 502 when the password couldn't be saved (the link still works). Both routes are limited to 10 requests a minute per address.

Update, reset, and delete

  • PATCH …/users/{user_id} takes enabled and/or roles (a full replacement list of app roles). Disabling a user or sending an empty roles list still works while the workspace is frozen; re-enabling or granting roles does not.
  • POST …/users/{user_id}/reset-password sends a set-password email and returns 202 with { "status": "accepted", "email_sent": true|false|null, "email_skipped_reason": … }, using the reasons above.
  • DELETE …/users/{user_id} returns 204. It also removes the user's invite links.

Agent tip: A site user with role user still cannot query a vault collection until that role has a collection ACL grant. New collections start with a grant for admin only. Suggest granting Access after creating the user if the next step is Data Vault.

App roles

MethodPathPermission
GET / POST/v1/workspaces/{workspace_id}/auth-rolesread / administer
PATCH…/auth-roles/{role_name}administer

Create body: name, permissions[], optional description. A permission ID that isn't in the permission catalog is 422, naming the unknown IDs.

SSO brokers

MethodPathPermission
GET / POST/v1/workspaces/{workspace_id}/auth/ssoread / administer
PATCH / DELETE…/auth/sso/{connection_id}administer
POST…/auth/sso/{connection_id}/test-loginadminister

OIDC/SAML fields plus optional client_secret on create/update. Optional organization_id on create links the broker to a tenant (409 if Multi-tenant is off).

Tenants

Requires auth.multi_tenant.enabled and an active Groundfloor realm. These are your app's tenants, not your Groundfloor organization.

MethodPathPermission
GET / POST/v1/workspaces/{workspace_id}/auth/tenantsread / administer
GET / PATCH / DELETE…/auth/tenants/{tenant_id}read / administer
GET / POST…/auth/tenants/{tenant_id}/membersread / administer
DELETE…/auth/tenants/{tenant_id}/members/{user_id}administer

Create body: name, optional alias, domains[], enabled. Member add: user_id or email (invite). An email that belongs to someone who can already sign in adds them without an invitation. Removing a member doesn't delete their login.

App users' tokens keep external_tenant_id as the workspace ID. Apps read the organization claim for tenant context — do not use external_tenant_id as the app tenant.

Activity

Auth-related events appear on the Audit API (GET …/workspaces/{id}/audit). There is no separate auth-audit route.

Public host discovery

Unauthenticated helpers (rate-limited):

MethodPathPurpose
GET/v1/public/workspaces/by-host?host=OIDC metadata for site host
GET/v1/public/brandingCell branding JSON
GET/v1/public/branding/theme.cssLogin CSS variables (workspace theme)
GET/v1/public/branding/assets/{kind}logo / favicon / login_background