Skip to main content

Service Accounts

Service Accounts are first-class machine identities in Admiral. They are the recommended way to give automated systems (CI/CD pipelines, scripts, monitoring, integrations) access to the platform.

Unlike Personal API Tokens, a service account:

  • Is independent of any human user, so it survives employee turnover.
  • Authenticates using the industry-standard OAuth2 client credentials flow.
  • Has access granted through policies, making its permissions explicit, scoped, and auditable.
  • Supports multiple keys for zero-downtime rotation.

Core Concepts

ConceptDescription
Service AccountThe machine identity. Belongs to one "home" organisation.
KeyA client_id / client_secret credential pair used to authenticate. A service account can hold multiple keys.
GrantA single (relation, scope) permission, e.g. admin on a fleet.
PolicyA reusable bundle of grant statements you attach to a service account.
Home organisationThe organisation the service account lives in. It is always a member of this organisation.

Every service account is automatically a member of its home organisation. This membership cannot be revoked while the service account exists.

Accessing Service Accounts

Service accounts are managed under IAM > Service Accounts.

You must be an admin, root, or owner of the organisation to create, delete, or modify a service account or its keys. Any member of the organisation can view service accounts and their granted access.

Creating a Service Account

The creation wizard walks through three steps.

Step 1 - Details

Provide:

  • Name (required) - e.g. CI/CD Pipeline
  • Description (optional) - e.g. Used by GitHub Actions
  • Key name (optional) - a label for the first key

Step 2 - Credentials

On creation, Admiral mints the first key and returns:

  • Client ID - e.g. sa_...
  • Client Secret
This is shown only once

The client secret is displayed a single time. Copy it, use "Download .env", or store it in your secret manager immediately. If lost, you must mint a new key.

Step 3 - Access

Choose how the service account gets its permissions:

  • Full access (organisation admin) - Creates and attaches an Administrator policy granting org-wide admin. The key can manage all fleets, configurations, rollouts, and devices. Use this only when you genuinely need a do-everything key.
  • Attach existing policies - Grant scoped, least-privilege access from policies you have already authored.
  • No access yet - Configure access later from the service account's detail page.
Least privilege

Prefer attaching a narrowly-scoped policy over granting full access. You can always widen access later, but a tightly-scoped key limits the blast radius if it leaks.

Authentication

Service accounts use the OAuth2 client credentials grant. Your workload exchanges its client_id and client_secret for a short-lived JWT access token, then uses that token as a bearer token on API requests.

Most API requests also require an X-Organization-ID header to set the organisation context for the call. Use the service account's home organisation ID (or a nested organisation it has access to).

1. Exchange credentials for an access token

curl -X POST https://api.admrl.co/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"

Response:

{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InNhLWtleS0x...",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": 1780637469
}
FieldDescription
access_tokenA signed JWT. Use it as a bearer token on API requests.
token_typeAlways Bearer.
expires_inLifetime in seconds (typically 3600).
expires_atAbsolute Unix expiry timestamp.

The access token is short-lived. Your long-lived secret is the key, which should be stored securely and never committed to source control. When the token expires, exchange the credentials again.

2. Call the API with the access token

Pass the token as a bearer token and set the organisation context:

curl -sS https://api.admrl.co/v1/devices \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Organization-ID: $ORG_ID"
{
"code": 200,
"counts": { "offline": 0, "online": 0, "total": 0 },
"data": [],
"msg": "Success",
"pagination": { "limit": 10, "page": 1, "total": 0, "totalPages": 0 }
}
Organisation context

The X-Organization-ID header determines which organisation the request operates against. A service account can only act in organisations it holds access to. Its home organisation ID is also embedded in the token's home_org claim.

Go Example

A minimal, idiomatic Go integration using golang.org/x/oauth2/clientcredentials. The clientcredentials config handles fetching and refreshing access tokens automatically. We wrap the HTTP client's transport so the required X-Organization-ID header is added to every request.

package main

import (
"context"
"io"
"log"
"net/http"
"os"

"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)

// orgHeaderTransport injects the organisation context header on every request.
type orgHeaderTransport struct {
orgID string
base http.RoundTripper
}

func (t *orgHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("X-Organization-ID", t.orgID)
return t.base.RoundTrip(req)
}

func main() {
baseURL := "https://api.admrl.co/v1"
orgID := os.Getenv("ORG_ID")

cfg := &clientcredentials.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
TokenURL: baseURL + "/oauth/token",
}

// cfg.Client handles fetching and refreshing access tokens automatically.
// We wrap the underlying HTTP client's transport to also send the org header.
httpClient := &http.Client{
Transport: &orgHeaderTransport{orgID: orgID, base: http.DefaultTransport},
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)

client := cfg.Client(ctx)

resp, err := client.Get(baseURL + "/devices")
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status %d: %s", resp.StatusCode, body)
}

log.Printf("Devices: %s", body)
}

Initialise the module, pull dependencies, set the environment variables, and run:

go mod init authtest
go mod tidy
export CLIENT_ID="sa_..."
export CLIENT_SECRET="..."
export ORG_ID="96c73a45-a70e-4ebd-b4a1-2b2892090088"
go run authtest.go

Alternative: only send the org header on API calls

In the example above, the oauth2.HTTPClient in the context is used for both the token exchange and the API requests, so the X-Organization-ID header is also sent on the token-fetch call. That is harmless, since the token endpoint ignores it.

If you prefer the org header to appear only on API calls and not the token exchange, wrap the transport around the oauth2 transport instead:

func main() {
baseURL := "https://api.admrl.co/v1"
orgID := os.Getenv("ORG_ID")

cfg := &clientcredentials.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
TokenURL: baseURL + "/oauth/token",
}

// Token source handles fetch/refresh of access tokens.
ts := cfg.TokenSource(context.Background())

// oauth2.Transport adds the bearer token; our transport adds the org header.
client := &http.Client{
Transport: &orgHeaderTransport{
orgID: orgID,
base: &oauth2.Transport{Source: ts, Base: http.DefaultTransport},
},
}

resp, err := client.Get(baseURL + "/devices")
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status %d: %s", resp.StatusCode, body)
}

log.Printf("Devices: %s", body)
}

Manual token exchange in Go

If you would rather not pull in the oauth2 library, exchange the token yourself and set both headers on each request:

package main

import (
"context"
"encoding/json"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)

type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
ExpiresAt int64 `json:"expires_at"`
}

const baseURL = "https://api.admrl.co/v1"

func fetchToken(ctx context.Context) (*tokenResponse, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", os.Getenv("CLIENT_ID"))
form.Set("client_secret", os.Getenv("CLIENT_SECRET"))

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
return &tr, nil
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

tok, err := fetchToken(ctx)
if err != nil {
log.Fatalf("token exchange failed: %v", err)
}

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/devices", nil)
req.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken)
req.Header.Set("X-Organization-ID", os.Getenv("ORG_ID"))

resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()

log.Printf("status: %s", resp.Status)
}

Managing Keys

Keys are managed on the service account's detail page under the Keys tab.

Minting a key

  1. Open the service account.
  2. Go to the Keys tab and click Add Key.
  3. Optionally set a name and an expiry.
  4. Copy the credentials shown - the secret is shown only once.

Zero-downtime rotation

A service account can hold multiple keys at once, which makes rotation safe:

  1. Create a new key on the service account.
  2. Update your systems to use the new client credentials.
  3. Verify the systems work with the new key.
  4. Revoke the old key.
Last key

If you revoke the only remaining key, the service account can no longer authenticate until a new key is minted.

Key fields

FieldDescription
NameOptional label to identify the key's purpose.
CreatedWhen the key was minted.
Last usedThe most recent time the key authenticated.
ExpiresWhen the key automatically stops working, or Never.

Viewing Access

The Access tab on a service account shows a summary of every grant it holds, grouped by scope type.

  • If the service account holds an organisation-wide role, a banner explains that resource-specific grants are already covered by it and do not need listing.
  • The Advanced section allows adding a one-off manual grant. Prefer attaching a policy instead, since policies are reusable and auditable.

See IAM, Policies & Access for the full access model.

Deleting a Service Account

Deleting a service account is permanent and immediate. All of its keys and all of its access are removed. Any system using its credentials will stop working at once.

Only admins, roots, or owners of the home organisation can delete a service account.

Next Steps