Skip to content
Like what we’re building? Star on GitHub

Iterion Cloud admin runbook ​

Audience. The platform operator who runs iterion server for multiple teams and the org admin who manages their team inside it. Two halves: §1 covers the platform-wide knobs only super-admins reach; §2 covers the org self-serve flows, on /teams/<id> for the team's own settings and on /integrations for everything wired to the outside world (webhooks, secrets, bot bindings, forge repos). Each step pairs the UI path with the equivalent curl so this page works for both the studio user and the CI script.

For deployment + chart values, start with cloud-deployment.md. For the bigger conceptual picture, see the Iterion Cloud overview. For the precise REST shapes, see cloud-rest-api.md.

Diagram: diagrams/data-model-identity.md — the identity data model (orgs, teams, users, memberships).


Part 1 — Platform operator (super-admin) ​

1.1 Bootstrap the super-admin ​

There are two paths, and which one you get depends on whether ITERION_BOOTSTRAP_ADMIN_PASSWORD is set.

Declarative (recommended for GitOps). Set ITERION_BOOTSTRAP_ADMIN_EMAIL andITERION_BOOTSTRAP_ADMIN_PASSWORD (typically from a k8s Secret). The account is created active with that password and reconciled to it on every boot: super-admin flag restored, status forced back to active, password reset only on drift so idempotent restarts are no-ops. The secret is authoritative — rotate by updating it and restarting, and note that a password changed through the UI reverts on the next restart by design. A password this reconciliation replaces at boot ends none of the account's sessions and none of its personal access tokens: after a rotation meant to shut someone out, sign the account out everywhere. Nothing is logged on this path.

One-time temp password (when no password is declared). Set only ITERION_BOOTSTRAP_ADMIN_EMAIL and roll the chart. The server creates the account on first boot if the users collection is empty and prints a one-time password at WARN level (cmd/iterion/server.go):

{"level":"warn","msg":"server: BOOTSTRAP super-admin created — email=ops@example.com temp_password=4xT0n… (rotate via POST /api/auth/password/change)"}

Recovery case (temp-password path only). If the operator missed the log (pod restarted, log aggregator misconfigured, …) and the bootstrap user is still in pending_password_change, restart the server pod — the bootstrap code path re-issues a fresh temp password for that one specific state. An already-active account is never force-reset this way. With ITERION_BOOTSTRAP_ADMIN_PASSWORD set this recovery is unnecessary: the declared secret already is the password.

After login, post to /api/auth/password/change to rotate, then unset ITERION_BOOTSTRAP_ADMIN_EMAIL on the next deploy (the guard is users.count()==0 so leaving it set is safe, but removing it is cleaner). On the declarative path you keep both variables set — reconciliation on every boot is the point, and rotating means changing the secret rather than calling the password-change endpoint.

1.2 Create an org and a first owner ​

bash
# Create the org. owner_email defaults to the calling super-admin.
curl -X POST https://iterion.example.com/api/admin/orgs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Corp","slug":"acme","owner_email":"alice@acme.example"}'

UI path: super-admin chip → Admin → Organisations → New.

The owner_email must already exist as a user. To pre-provision them, either let them register through /api/auth/register (if config.auth.signupMode: open) or send them an invitation token through the org they end up owning.

1.3 Set quotas + caps ​

bash
curl -X PATCH https://iterion.example.com/api/admin/orgs/$ORG_ID \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "monthly_run_quota":     1000,
    "monthly_cost_cap_usd":   80.0,
    "memory_quota_bytes":     1073741824
  }'

UI path: Admin → Organisations → Acme → Limits → Save.

Every field is optional; 0 means "inherit the platform default". For the launch-gate limits that default comes from the ITERION_ORG_DEFAULT_* env vars — see quotas-and-limits.md. memory_quota_bytes is the exception: it falls back to the compiled knowledge.DefaultOrgAggregateQuota, not to any ITERION_ORG_DEFAULT_* var, and the memory ceilings are tuned with ITERION_MEMORY_QUOTA_ORG_TOTAL / ITERION_MEMORY_QUOTA_<VISIBILITY> on the filesystem adapter. Negative values are 400s. The handler also propagates a memory_quota_bytes change into the enforced memory counter via SetTenantQuota (pkg/server/admin_orgs_routes.go) — without that step the change wouldn't take effect, only the displayed ceiling would.

max_concurrent_runs and launch_rate_per_min are team fields: set them with PATCH /api/orgs/$ORG_ID/teams/$TEAM_ID/caps, which 422s a value above the matching ITERION_ORG_DEFAULT_* ceiling. The org handler drops a field it does not declare, so sending them here answers 200 and changes nothing.

See quotas-and-limits.md for what each limit actually does at run launch and how to debug a denial.

1.4 Suspend or set an org to read-only ​

Diagram: Flow — org and team lifecycle: suspend, soft-delete, purge

bash
curl -X POST https://iterion.example.com/api/admin/orgs/$ORG_ID/status \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"status":"suspended","reason":"non-payment 2026-06"}'

status is one of active / suspended / read_only (pkg/identity/types.go:TeamStatus). Suspended and read-only orgs deny every run launch with reason org_suspended and HTTP 403; reads (the studio, the API, the run console) still work so the org's members can see what they previously ran. The reason is recorded on the audit row.

The handler also accepts pending_deletion, the soft-delete state that DELETE /api/admin/orgs/{id} writes together with a purge deadline: posted here it blocks the org without that deadline, and the purge sweeper only lists orgs whose purge_after has passed.

What the nightly purge (02:00 UTC, on the one replica holding the org-purge-sweeper lease, plus a catch-up at each lease term's start) removes once that deadline has passed (pkg/cloud/orgsweep/sweep.go): every run of every team the org still lists — a team deleted through the team door took no runs with it, and the purge no longer sees them (flow-org-team-lifecycle.md) — one by one through the run store, with the objects it owns in the bucket (artifacts/, attachments/, tools/, ir/<run>.json and the bundle snapshots ir/<run>-bundle-<digest>.json, sessions/, runfiles/ under the run id); then every team-scoped and org-scoped Mongo collection, the audit rows keyed by the org AND by each of its teams, the org's sealed credentials and routing policies; then the identity rows (teams, memberships, invitations, the org). What it leaves, on purpose: the users rows and their SSO links — a user outlives their orgs and may be re-invited; erasing an account is a separate, explicit gesture — a user's own sessions and personal forfait. The order is the safety: no Mongo row is swept while a run's objects are still in the bucket, so a failed or interrupted purge leaves the org pending and the next sweep, on any replica, resumes it. A run that is not terminal (running, queued, paused) is not deleted — the engine may still write its objects — and withholds the sweep the same way until it ends: cancel it to let the purge finish.

ITERION_ORG_PURGE_RUN_BATCH bounds the run deletes one pass attempts per org (a run delete is paginated S3 work; failed attempts count): once reached, the pass stops before any collection sweep, logs resumes at the next sweep, and the org is purged over several nights. Unset or 0 is no bound — the org goes in one pass; a value that is not a non-negative integer refuses to start the server rather than read as 0.

Restoring during a purge. POST /api/admin/orgs/{id}/restore is accepted as long as the org is pending_deletion, including after its purge_after has passed. The purge re-reads the org before every run delete and before its first collection sweep, so a restore stops it at the next step — but the runs a pass already deleted (with their objects) are gone for good: a restore after the deadline, or between two nights of a batched purge, returns the org without them. The sweep logs it as a warning: left purge eligibility mid-purge … N run(s) already deleted.

A purge that never finishes. A run whose delete keeps failing (a bucket permission, an object under legal hold) or that never ends keeps the org pending_deletion with a warning each night — orgsweep: purge org <id> … could not be deleted, the Mongo sweep is withheld. Fix the cause (cancel the run, repair the bucket access) and the next sweep finishes. When the objects cannot be deleted at all, set ITERION_ORG_PURGE_FORCE_SWEEP=<org id> on the server and restart it: the purge of that org goes past the runs it cannot delete, names each in a warning, and leaves their objects in the bucket — unattributed, so record the run ids from that warning before removing the variable.

UI path: Admin → Organisations → Acme → toggle status.

1.5 Audit log ​

Every control-plane mutation is recorded, and every sign-in — accepted or refused, every provider (pkg/audit/audit.go, ADR-157):

bash
# All platform-level actions (super-admin and org status changes).
curl "https://iterion.example.com/api/admin/audit?limit=100"

# Filter by action token or actor.
curl "https://iterion.example.com/api/admin/audit?action=org.status_changed"
curl "https://iterion.example.com/api/admin/audit?actor=$USER_ID&from=2026-06-01T00:00:00Z"

The stable action tokens (grep auditTenant/auditOrg/auditPlatform call sites for the full list):

org.created · org.updated · org.status_changed · org.settings_updated · org.deletion_scheduled · org.deletion_canceled · org_member.added · org_member.role_changed · org_member.removed · org_invitation.created · org_invitation.deleted · team.created · team.updated · team.status_changed · team.caps_updated · team.deleted · member.added · user.password_changed · user.password_reset · user.sessions_revoked · member.removed · member.role_changed · invitation.created · invitation.accepted · invitation.deleted · byok.created · byok.updated · byok.deleted · secret.created · secret.updated · secret.deleted · binding.created · binding.updated · binding.deleted · webhook.created · webhook.updated · webhook.rotated · webhook.deleted · pat.created · pat.revoked · dlq.replayed · dlq.discarded · auth.login.succeeded · auth.login.failed · run.launched · run.forked · run.deleted · run.retry.*

run.launched is one tenant row per launched run, whatever launched it, keyed by the run id (target_id). A launch that returns an error but may still have left a run behind — its run persisted before the failure, or a queue publish that reports failure after the message may have landed — writes its row too, with meta.outcome: may_have_started; a launch refused before anything could execute writes none. The actor is the principal the launch ran under: the signed-in user for a Studio, CLI or API launch — the operator's "launch now" on a pipeline card included (actor_kind: user), the webhook principal webhook:<config-id> for an inbound delivery (actor_kind: webhook), and a daemon of this process for the rest (actor_kind: system — scheduler:<bot>, board-dispatcher, trigger-spine; the local pipeline-admission loop is wired too but runs only outside cloud mode, where no audit store exists, so its actor never appears in a cloud log). meta names the bot, the workflow file, the repository, the trigger — the run's own provenance when it has one (dispatcher | schedule | studio_chat | webhook), else the client that asked (studio for a browser, api for the CLI or any other API client), absent for a daemon — and the delivery_id or schedule_id that caused it; never the launch vars. IP and user-agent are recorded when an HTTP request served the launch (a webhook row carries the delivery's source address), empty for a daemon. Every launch surface crosses one choke point (pkg/server/runs_launch_audit.go), and a test keeps it the only one. A fork is a new run that crosses no launch: POST /api/runs/{id}/fork writes run.forked instead — the operator as actor, the child run as target_id, meta.parent_run_id naming the run it was forked from. A resume continues an existing run and writes no row.

Two scopes: tenant rows and platform rows (super-admin only at /api/admin/audit). A tenant row is keyed on a team or on an org, and each route lists its own key: GET /api/teams/{id}/audit (team admins) the team-keyed rows, GET /api/orgs/{id}/audit (org admins) the org-keyed ones — the org mutations and every accepted sign-in. Each row carries the actor's IP + user-agent + a small meta blob (never secret material). Mongo TTL: 400 days (pkg/audit/audit.go:RetentionDays).

Sign-ins follow their own rules. An accepted one (auth.login.succeeded) is a tenant row on the org the session is active in — read it at GET /api/orgs/{id}/audit, not on the team route — actor = the account, meta.method one of password github google oidc org_ssodesktop (oidc is every deployment-wide connector but the two built-in ones, the one the deployment names sso included; org_sso is an org's own IdP), meta.flow when the session is the side effect of a registration, a password change or a reset, meta.provider the connector slug, meta.team_id the active team. A refused one (auth.login.failed) is a platform row with no tenant — a refusal names no org, and an org admin must not read attempts on addresses that are not their members — with meta.reason typed (unknown_account, bad_password, locked, disabled, password_change_required, link_required, sso_restricted, ticket_invalid, reset_token_invalid, agent_binding, password_changed_sign_in, error, …) and, where the attempt named an address, meta.subject_hash: a 16-hex peppered HMAC of the address under a key derived from the signing key, never the address itself and never a bare hash of it (reversible by dictionary). The digest lets you follow one campaign on one address:

bash
curl "https://iterion.example.com/api/admin/audit?action=auth.login.failed&from=2026-11-01T00:00:00Z"

A refusal's target_id is the account only where the server resolved it (the self-service password change, a desktop ticket's account, a password change committed without a session — password_changed_sign_in, beside its user.password_reset / user.password_changed row); an attempt by address names the address only through the digest. A session refresh writes no row (the session lineage records it). An OIDC callback refused before an identity resolves — an expired or replayed state, a failed code exchange, an error the IdP returned — writes no row: it names no subject, and the server's Warn log is its trace; the login-CSRF guard (agent_binding) is the exception and is recorded.

What the recorded IP is, and what bounds a flood. No deployment setting yet trusts a forwarded header: the server's trusted-proxy list is wired to no chart value or environment variable (#2673). The recorded IP is therefore the immediate peer's address and port — behind an ingress, the ingress pod's — and the per-IP rate-limit bucket is keyed on that same value, so it is per TCP connection rather than per client. What bounds the rows today, per route: POST /api/auth/login and POST /api/auth/password/change also limit per attempted address (burst 5, 5/min), so one address cannot be hammered faster than that, but attempts spread over many addresses and fresh connections are not bounded; POST /api/auth/password/reset/confirm and POST /api/auth/desktop/exchange have the per-IP bucket only; POST /api/auth/register its own (burst 3); the OIDC callback has no limiter, and each of its rows costs a full IdP round trip. A request the limiter throttles (429) reaches no sign-in and writes no row.

1.6 User admin — force a password rotation ​

bash
# List users (paginated with ?offset=&limit=; ?q= narrows the page to the
# accounts whose email starts with it, plus the one whose id equals it).
curl "https://iterion.example.com/api/admin/users?limit=200"

# Find one account without paging — a prefix match (case-insensitive), not a
# substring, so it rides the unique index on `email`. Knowing only the middle
# of an address still means paging.
curl "https://iterion.example.com/api/admin/users?q=alice@"

# Force a password change on next login.
curl -X PATCH https://iterion.example.com/api/admin/users/$USER_ID \
  -d '{"status":"pending_password_change"}'

Setting the status to pending_password_change sends a password sign-in to the rotation via POST /api/auth/password/change (the standard auth-rotation endpoint also covers the bootstrap and post-reset flows) and refuses an SSO sign-in (the login page shows sso_error=password_change_required); meanwhile it refuses every refresh and every credential a session would mint — a team switch, a personal access token, a WebSocket ticket, an SSO link — and refuses the account's personal access tokens: the user's open sessions end at their next renewal, within the access-token lifetime, 15 minutes by default, and the rotation itself revokes every session born before it. An account that signs in through SSO only has no password to rotate: its way out is the emailed reset ("Forgot your password?"), which sets a password and the account active — or an administrator setting the status back to active. Disabling the user (status:"disabled") refuses every login attempt while keeping their data, and ends every session, revokes every personal access token and removes every SSO identity a signed-in session connected once the status has landed; a revocation that fails answers 500 with the account disabled — repeat the request. The super-admin declared with ITERION_BOOTSTRAP_ADMIN_PASSWORD is the exception: the next restart's reconciliation (§1.1) sets it active and super-admin again — the sessions, tokens and identities the disable ended stay ended — so remove the ITERION_BOOTSTRAP_ADMIN_* declaration before disabling that account for good.

1.7 DLQ triage ​

When a run exhausts its NATS redelivery budget (default 8) the runner parks a copy on the DLQ stream and flips the run to failed_resumable (pkg/runner/loop.go, look for parking on DLQ). The same park happens when a run's queue message is rejected for a schema version mismatch through the whole redelivery budget during a rolling upgrade — its reason header reads queue: schema version: … and the recovery procedure is docs/cloud-queue-schema-rollout.md. Triage:

bash
# List parked messages. cursor advances; next_cursor=0 means exhausted.
curl "https://iterion.example.com/api/admin/dlq?limit=20"

# Peek (full RunMessage payload).
curl "https://iterion.example.com/api/admin/dlq/$SEQ"

# Replay (re-publish onto iterion.queue.runs, then delete from DLQ).
curl -X POST "https://iterion.example.com/api/admin/dlq/$SEQ/replay"

# Discard permanently.
curl -X DELETE "https://iterion.example.com/api/admin/dlq/$SEQ"

A replay is only a delivery, so it is refused (409, the copy kept) when a runner would drop the message on admission: the run cancelled, finished, failed, waiting for an answer, rewound for an explicit resume, queued again since the message was published, or parked on a code no redelivery changes — DLQ_PARKED included, the code every park writes. A parked run is recovered by resuming it (POST /api/runs/{id}/resume, iterion remote runs resume), which re-queues it as a new attempt at the current schema version; discard the parked copy afterwards. The refusal names the way out.

Behind the scenes (pkg/queue/nats/dlq.go): each DLQ message carries Iterion-DLQ-Reason, Iterion-Run-Id, Iterion-Tenant-Id, Iterion-Num-Delivered headers so the list view explains why it parked without re-decoding the body. Replay salts the Nats-Msg-Id with the DLQ sequence so JetStream's dedup window can't silently swallow the second attempt; the new in-flight run is admitted through the launch gate like any other publish.

1.8 Orphan sweeper ​

A runner pod that dies between claiming a run and writing its terminal status strands the run row in running (or queued) forever — UI shows an eternal spinner, iterion resume rejects ("not a resumable status"). The orphan sweeper closes that gap (pkg/server/queue_sweeper.go):

  • Scans every 60s for queued past the redelivery window + margin (~2 h 40 with the defaults: a resume that meets a held run lease may wait out the runner's lease ceiling) or running > 10 min AND no current NATS-KV lease.
  • CAS-flips matched rows to failed_resumable so iterion resume (or the studio Retry button) lights up.
  • Bumps iterion_runs_orphan_recovered_total. Set the IterionOrphanRunsRecovered alert (default in the starter pack) to catch pod churn early.

The PrometheusRule pack ships the alert at increase(iterion_runs_orphan_recovered_total[30m]) > 0.

1.9 SMTP configuration ​

Transactional email (invitations + self-service password reset) is opt in. Without SMTP, iterion falls back to a LogMailer that prints would-be messages at WARN level — fine for dev, useless for production (pkg/mail/log.go). Configure via env on the server pod:

Env varRequiredEffect
ITERION_SMTP_HOSTyes (enables real mailer)Relay hostname
ITERION_SMTP_PORTusually 587TCP port
ITERION_SMTP_USERNAMEyes (typically)SMTP AUTH user
ITERION_SMTP_PASSWORDyes (typically)SMTP AUTH password
ITERION_SMTP_FROMyesEnvelope + From header, e.g. iterion <no-reply@example.org>
ITERION_SMTP_STARTTLSdefault trueUpgrade before AUTH (only disable against localhost)

The chart's config.smtp.* block fills the first four; the chart's secrets.smtp.{username,password} (or secrets.smtp.existingSecret) carries the credentials (charts/iterion/templates/secret-smtp.yaml). When ITERION_SMTP_HOST is set, the boot log shows server: SMTP mailer enabled (host=…). /api/server/info exposes email_enabled so the SPA hides the forgot-password link when it's off.

The legal notice, privacy policy, terms of use and accessibility statement are deployment configuration (ADR-160); the chart's config.legal.* block fills every variable below (cloud-admin.md).

Env varRequiredEffect
ITERION_LEGAL_DOCUMENTS_DIRnoDirectory of <slug>.md markdown files (mentions-legales, confidentialite, cgu, accessibilite), read once at startup; a stray or empty file refuses the start
ITERION_LEGAL_CONTACT_EMAILnoThe mailto: contact every legal page offers
ITERION_LEGAL_ACCESSIBILITY_STATUSnonon_compliant | partially_compliant | compliant; empty = not published
ITERION_LEGAL_ACCESSIBILITY_DATEnoDate the statement was established, YYYY-MM-DD
ITERION_LEGAL_ACCESSIBILITY_RATEnoConformance rate in percent from a full audit; refused without a status

/api/server/info carries a legal block (configured documents, contact, status) the SPA reads to decide which links to show.

1.11 PrometheusRule + PodMonitor ​

The chart's metrics.podMonitor.enabled=true deploys a PodMonitor that scrapes both server and runner /metrics; metrics.prometheusRule.enabled=true ships the starter alert pack (charts/iterion/templates/prometheus-rule.yaml). Both depend on the prometheus-operator CRDs being installed in the cluster — without them, helm will skip the resource.

/metrics is ClusterIP-only by design; never expose it through an Ingress. See quotas-and-limits.md for the exhaustive metric list and charts/iterion/README.md for the chart values.


Part 2 — Org admin (team owner / admin) ​

You're a member of one or more orgs and your role in at least one is admin or owner. Everything here is self-serve from the studio: https://iterion.example.com/teams/<id> for the team's own settings, https://iterion.example.com/integrations for the integrations the team consumes.

2.1 Create an inbound webhook from the studio ​

UI: Integrations → Webhooks (/integrations?tab=webhooks) → "Create webhook".

  1. Pick a provider: GitLab, GitHub, Forgejo/Gitea, or Generic.
  2. Pick the bot scope: one bot, several bots, or "wildcard" (any bot the catalog ships — needs an explicit checkbox; flagged in audit).
  3. (Optional) project allowlist (e.g. acme/repo, acme/*, or *).
  4. (Optional) event allowlist (defaults are sensible per provider).
  5. (Optional) key_overrides mapping a provider → BYOK key id so this webhook bills against a specific key (see webhooks.md).
  6. (Optional) launch_vars — operator-pinned vars stamped on every run.

Iterion mints an iwh_… token shown exactly once. Copy it and the URL /api/webhooks/<provider>/<id> and paste them into the forge:

  • GitLab — Settings → Webhooks → URL + Secret Token. Tick "Merge request events" and "Comments" (for /revi).
  • GitHub — Settings → Webhooks → Payload URL + Secret. Content-type application/json. Subscribe to "Pull requests" only.
  • Forgejo/Gitea — Settings → Webhooks → Target URL + Secret. Pick "Pull Request" events.
  • Generic — your own client. Send X-Iterion-Webhook-Token: iwh_… in the request header.

CLI equivalent:

bash
curl -X POST https://iterion.example.com/api/teams/$TEAM_ID/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "name":     "GitLab MR review",
    "provider": "gitlab",
    "bot_ids":  ["review-pr"],
    "project_allowlist": ["acme/*"]
  }'
# Response: { "config": { ... }, "token": "iwh_…" }

Rotate later via POST /api/teams/{id}/webhooks/{webhook_id}/rotate — a fresh iwh_ is returned and the HMAC seal is refreshed in lockstep.

The full reference (auth modes, per-provider filters, idempotency, the /revi command) lives in webhooks.md.

2.2 Add generic secrets and bind them to a bot ​

A generic secret is a string value the org wants its bots to use — a forge personal-access-token, a deploy key, etc. A bot-secret binding ties a stored secret to one bot under the name the workflow declares in its secrets: block, optionally narrowing the egress allowlist further than the workflow does (ADR docs/adr/018).

bash
# 1. Store the secret (team-scoped). Returns metadata only; the
#    plaintext is sealed at rest.
curl -X POST https://iterion.example.com/api/teams/$TEAM_ID/secrets \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"name":"gitlab_pat","value":"glpat-xxx","allowed_hosts":["gitlab.example.com"]}'
# Response: { "id": "sec_…", "name": "gitlab_pat", "last4": "…", … }

# 2. Bind it to the review-pr bot under the workflow name "forge_token"
#    with hosts intersected to gitlab.example.com only.
curl -X POST https://iterion.example.com/api/teams/$TEAM_ID/bots/review-pr/bindings \
  -d '{
    "secret_id": "sec_…",
    "secret_name_for_workflow": "forge_token",
    "allowed_hosts": ["gitlab.example.com"]
  }'

UI path: Integrations → Secrets (/integrations?tab=secrets) → "Add team secret"; then Integrations → Bot bindings → pick review-pr → "Add binding" → kind secret → pick the stored secret. The same dialog's context kind binds a pinned document instead (source repository, ref, path, optional read credential), and the bindings table shows each row's kind plus the engine's degraded flag when a launch could not materialise it.

The full binding resolution chain (user > binding > team) and what allowed_hosts enforces is in secrets-reference.md.

Binding a context document to a bot ​

A binding of kind context ties REFERENCE MATERIAL — not a credential — to one bot: a file inside a git repository the operator controls, materialised as a run attachment of every run of that bot (schedules and webhook launches included), under a name the workflow declares in its attachments: block (ADR 147).

bash
curl -X POST https://iterion.example.com/api/teams/$TEAM_ID/bots/doki/bindings \
  -d '{
    "kind": "context",
    "secret_name_for_workflow": "handbook",
    "context": {
      "git_url": "https://gitlab.example.com/org/doki-context.git",
      "ref": "v1.4.2",
      "path": "handbook/pricing.pdf",
      "secret_id": "sec_…"
    }
  }'
  • secret_name_for_workflow is the name the workflow declares in attachments: — here handbook. A name the workflow does not declare delivers a file no node can reach, so the binding is skipped (and flagged) rather than stored unread.
  • Pin a tag or sha in ref. A moving branch makes the document change under the operator's feet — the silent-drift failure the whole pinned-source design exists to prevent (pkg/pluginsource's doctrine, reused).
  • context.secret_id is the READ credential (a stored team-scoped PAT or deploy key), referenced by id, never inlined; omit it for a public repository. A context binding exposes no secret to the workflow — secret_id at the top level stays empty for this kind.
  • Precedence when several sources claim the same attachment name: author < operator < launch — a bundle's attachments/ default is the author's suggestion, a context binding overrides it, and a per-launch upload overrides both.
  • A binding that cannot be materialised (remote refuses the pin, path missing from the checkout, document past the size bound) does NOT fail the launch: the run proceeds without it, and the binding is flagged degraded_reason on the list endpoint until a launch resolves it again. The flag is the readout — a silently missing context is the defect this feature exists to fix. Note the bound applies to the delivered document (default 50 MiB), not to the fetch: the clone of the source repository is depth-1 but otherwise unbounded, so point bindings at small dedicated context repositories.

On the wire the document travels as a run attachment (blob-store reference in cloud), so the runner pod reads it through the ordinary attachment contract — no queue-message change.

2.3 Watch usage ​

UI path: /orgs/<id> → Usage. The run/cost counters are org-scoped (they sum every team in the org), so the view and the API are both keyed on the org:

bash
curl https://iterion.example.com/api/orgs/$ORG_ID/usage

The view fields are documented in quotas-and-limits.md. Members can read it; only admins can change the underlying limits (that's the super-admin's /api/admin/orgs/{id} endpoint above).

2.4 Read the team audit log ​

bash
curl "https://iterion.example.com/api/teams/$TEAM_ID/audit?limit=100"
# Filter:
curl "https://iterion.example.com/api/teams/$TEAM_ID/audit?action=byok.created"
curl "https://iterion.example.com/api/teams/$TEAM_ID/audit?actor=$USER_ID"

UI path: /teams/<id> → Audit log.

Requires team admin (not just membership) because rows expose member emails and IPs. Tokens are stable and listed in §1.5.

2.5 Invite members ​

bash
# Mint an invitation token. The token is returned exactly once.
curl -X POST https://iterion.example.com/api/teams/$TEAM_ID/invitations \
  -d '{"email":"bob@acme.example","role":"member"}'
# Response: { "invitation": { … }, "token": "…" }

UI path: /teams/<id> → Team access → "Invite a member".

When SMTP is configured, the new member receives an email with a link to ${PUBLIC_URL}/invitations/accept?token=… (the SPA accept page, which resolves the token via GET /api/auth/invitations/lookup) and creates an account from there. Without SMTP, the in-band response token must be sent to them out-of-band (email / chat / SMS).

Invitations expire in 7 days; revoke with DELETE /api/teams/{id}/invitations/{invite_id}.

2.6 Personal access tokens for CI ​

PATs (iap_…) are long-lived bearer credentials for programmatic API access where the 15-minute JWT + refresh dance is impractical (pkg/pat/pat.go). They authenticate as the issuing user with that user's role (including super-admin if applicable); v1 has no scope axis.

bash
# Mint a PAT pinned to one team, expiring in 90 days.
curl -X POST https://iterion.example.com/api/me/tokens \
  -d '{"name":"github-actions","team_id":"team_…","expires_in_days":90}'
# Response: { "pat": { … }, "token": "iap_…" }

# Use it on any /api/* request as the bearer:
curl https://iterion.example.com/api/runs \
  -H "Authorization: Bearer iap_…"

# List + revoke.
curl https://iterion.example.com/api/me/tokens
curl -X DELETE https://iterion.example.com/api/me/tokens/<token_id>

UI path: /account → Personal access tokens.

The platform operator can cap every PAT's lifetime via ITERION_PAT_MAX_TTL (Go duration, e.g. 2160h = 90 days). A missing or longer expiry is clamped to the ceiling at mint time (pkg/server/pat_routes.go:handleCreatePAT).

Mitigations against the unscoped-bearer risk: the optional team pin (scoped membership re-checked on every use), the optional expiry, the platform ITERION_PAT_MAX_TTL, instant revocation, and audit rows on create / revoke. Member removal kills the PAT immediately — the membership re-check at every use returns "token team unavailable".


Where things live ​

TopicFile
Full chart values + secret tablecharts/iterion/README.md
Inbound webhook reference (auth modes, per-provider behaviour)webhooks.md
Quota / metering / denial reasons / metricsquotas-and-limits.md
Every kind of secret + where it's resolvedsecrets-reference.md
Every REST endpoint + auth classcloud-rest-api.md
Control plane vs data plane + queue internalscloud-architecture.md
The operator runbook (chart install, secrets, NetworkPolicy)cloud-deployment.md
The end-user-side flows (login, BYOK, OAuth-forfait)cloud-user.md