feat(api): add API-provisionable service accounts#9399
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughService-account support adds transactional workspace-scoped bot provisioning through a management command and admin API, token minting, rotation, revocation, and decommissioning, with one-time token responses, activity-log redaction, authorization checks, tests, and documentation. ChangesService Account Lifecycle
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Admin
participant ServiceAccountAPIEndpoint
participant create_service_account
participant APIToken
participant APITokenLogMiddleware
Admin->>ServiceAccountAPIEndpoint: POST service-account request
ServiceAccountAPIEndpoint->>create_service_account: validate and provision account
create_service_account->>APIToken: mint workspace-scoped token
create_service_account-->>ServiceAccountAPIEndpoint: account and plaintext token
ServiceAccountAPIEndpoint->>APITokenLogMiddleware: mark response for redaction
ServiceAccountAPIEndpoint-->>Admin: HTTP 201 with one-time token
APITokenLogMiddleware-->>APITokenLogMiddleware: persist "[REDACTED]" response body
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds first-class, API-provisionable “service accounts” to enable fully automated workspace provisioning: a bot user + workspace membership + workspace-scoped API token created atomically, with safeguards to prevent interactive login and to avoid persisting minted tokens in API activity logs.
Changes:
- Introduces shared service-account provisioning utility used by both a new management command and a new admin-scoped HTTP endpoint.
- Adds response-body redaction support to the API token logging middleware to prevent logging newly minted credentials.
- Adds contract/unit tests plus documentation covering service-account behavior and surfaces.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/service-accounts.md | Documents service accounts, roles, command usage, and the admin endpoint (including token handling/logging notes). |
| apps/api/plane/utils/service_account.py | Core transactional provisioning logic for bot user + membership + token. |
| apps/api/plane/tests/unit/middleware/test_logger.py | Adds tests proving response bodies are logged by default and redacted when flagged. |
| apps/api/plane/tests/contract/api/test_service_account.py | Contract tests for both the management command and the HTTP endpoint, including attribution and log redaction. |
| apps/api/plane/middleware/logger.py | Adds redact_response_body() + attribute flagging and middleware support to redact logged response bodies. |
| apps/api/plane/db/models/user.py | Extends BotTypeEnum with SERVICE bot type. |
| apps/api/plane/db/management/commands/create_service_account.py | New management command to create a service account and print the minted token once. |
| apps/api/plane/api/views/service_account.py | New admin-only endpoint to create service accounts and return the token once (with log redaction). |
| apps/api/plane/api/views/init.py | Exports the new service account API view. |
| apps/api/plane/api/urls/service_account.py | Adds route for POST /api/v1/workspaces/{slug}/service-accounts/. |
| apps/api/plane/api/urls/init.py | Includes service-account URL patterns in the API router. |
| apps/api/plane/api/serializers/service_account.py | Adds request/response serializers for service-account provisioning. |
| apps/api/plane/api/serializers/init.py | Exports the new service-account serializers. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/plane/db/management/commands/create_service_account.py`:
- Around line 42-57: Wrap the create_service_account call in handle with an
IntegrityError handler and re-raise a readable CommandError for email uniqueness
failures, covering both the user-supplied email race and synthetic uuid4 email
collisions. Preserve the existing validation and successful creation behavior.
In `@apps/api/plane/utils/service_account.py`:
- Around line 56-64: Update resolve_service_account_role so integer inputs are
validated against the allowed values in SERVICE_ACCOUNT_ROLES before returning;
preserve valid integers and raise the same ValueError style used for invalid
string roles when an integer is unsupported.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6a2e8f2d-d3dc-4072-b115-11c1dbf1cd76
📒 Files selected for processing (13)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/service_account.pyapps/api/plane/api/urls/__init__.pyapps/api/plane/api/urls/service_account.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/service_account.pyapps/api/plane/db/management/commands/create_service_account.pyapps/api/plane/db/models/user.pyapps/api/plane/middleware/logger.pyapps/api/plane/tests/contract/api/test_service_account.pyapps/api/plane/tests/unit/middleware/test_logger.pyapps/api/plane/utils/service_account.pydocs/service-accounts.md
There was a problem hiding this comment.
🧹 Nitpick comments (1)
docs/service-accounts.md (1)
67-67: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSpecify a language for fenced code blocks.
The code blocks at lines 67 and 89 lack a language specifier, triggering markdownlint MD040. Use
textfor the command output block andhttpfor the HTTP request example.📝 Proposed fix
-``` +```text Service account created successfully user_id : 1c2f...c7f3And at line 89:
-``` +```http POST /api/v1/workspaces/{slug}/service-accounts/🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/service-accounts.md` at line 67, Update the two fenced code blocks in the service-accounts documentation: add the text language specifier to the command output block and the http language specifier to the HTTP request example, without changing their contents.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@docs/service-accounts.md`:
- Line 67: Update the two fenced code blocks in the service-accounts
documentation: add the text language specifier to the command output block and
the http language specifier to the HTTP request example, without changing their
contents.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a0439c2a-aad6-4ff1-a43e-621418c93b39
📒 Files selected for processing (14)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/service_account.pyapps/api/plane/api/urls/__init__.pyapps/api/plane/api/urls/service_account.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/service_account.pyapps/api/plane/db/management/commands/create_service_account.pyapps/api/plane/db/models/user.pyapps/api/plane/middleware/logger.pyapps/api/plane/tests/contract/api/test_service_account.pyapps/api/plane/tests/unit/middleware/test_logger.pyapps/api/plane/tests/unit/utils/test_service_account.pyapps/api/plane/utils/service_account.pydocs/service-accounts.md
🚧 Files skipped from review as they are similar to previous changes (11)
- apps/api/plane/api/serializers/init.py
- apps/api/plane/api/urls/init.py
- apps/api/plane/api/views/init.py
- apps/api/plane/api/urls/service_account.py
- apps/api/plane/db/management/commands/create_service_account.py
- apps/api/plane/api/serializers/service_account.py
- apps/api/plane/api/views/service_account.py
- apps/api/plane/tests/contract/api/test_service_account.py
- apps/api/plane/middleware/logger.py
- apps/api/plane/db/models/user.py
- apps/api/plane/utils/service_account.py
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
apps/api/plane/api/views/service_account.py:309
- The revoke endpoint will delete any token for this user/workspace, including non-service tokens (is_service=False). To keep the lifecycle endpoints scoped to service-account tokens, consider filtering by is_service/user_type as well.
token = APIToken.objects.filter(id=token_id, user_id=user_id, workspace=member.workspace).first()
if token is None:
return Response(status=status.HTTP_404_NOT_FOUND)
apps/api/plane/api/views/service_account.py:344
- The rotate endpoint can currently rotate any token for this user/workspace (including non-service tokens), and it will also rotate an already-inactive token—allowing repeated calls to mint unlimited replacements from the same old token. Consider scoping rotation to active service tokens only.
token = APIToken.objects.filter(id=token_id, user_id=user_id, workspace=member.workspace).first()
if token is None:
return Response(status=status.HTTP_404_NOT_FOUND)
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/plane/utils/service_account.py`:
- Around line 157-195: Update rotate_service_account_token so the replacement
inherits token.expired_at when expired_at is omitted, while preserving an
explicitly supplied new expiry. Pass the resolved expiry to
mint_service_account_token and leave the existing deactivation behavior
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c55ac6d8-1e81-4375-82ea-09c52a8efbd0
📒 Files selected for processing (9)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/service_account.pyapps/api/plane/api/urls/service_account.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/service_account.pyapps/api/plane/db/management/commands/create_service_account.pyapps/api/plane/tests/contract/api/test_service_account.pyapps/api/plane/utils/service_account.pydocs/service-accounts.md
🚧 Files skipped from review as they are similar to previous changes (1)
- docs/service-accounts.md
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/api/plane/tests/contract/api/test_service_account.py (1)
517-534: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNo coverage for minting without a
label.Every mint test supplies
label, so the omitted-label path (data.get("label") → None) is never exercised — the same gap flagged onapps/api/plane/api/views/service_account.py. Add a case posting{}and asserting a 201 with a usable label.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/tests/contract/api/test_service_account.py` around lines 517 - 534, The service-account token mint tests do not cover requests without a label. Add a test alongside test_mint_token_returns_value_once_and_authenticates that posts an empty JSON payload to the token endpoint, asserts HTTP 201, and verifies the response contains a usable non-empty label and token.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/api/plane/tests/contract/api/test_service_account.py`:
- Around line 517-534: The service-account token mint tests do not cover
requests without a label. Add a test alongside
test_mint_token_returns_value_once_and_authenticates that posts an empty JSON
payload to the token endpoint, asserts HTTP 201, and verifies the response
contains a usable non-empty label and token.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d738ed7e-fad3-4c4b-86ac-5a5c8ec84081
📒 Files selected for processing (13)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/service_account.pyapps/api/plane/api/urls/__init__.pyapps/api/plane/api/urls/service_account.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/service_account.pyapps/api/plane/db/management/commands/create_service_account.pyapps/api/plane/db/models/user.pyapps/api/plane/middleware/logger.pyapps/api/plane/tests/contract/api/test_service_account.pyapps/api/plane/tests/unit/middleware/test_logger.pyapps/api/plane/tests/unit/utils/test_service_account.pyapps/api/plane/utils/service_account.py
🚧 Files skipped from review as they are similar to previous changes (8)
- apps/api/plane/api/serializers/init.py
- apps/api/plane/api/views/init.py
- apps/api/plane/tests/unit/utils/test_service_account.py
- apps/api/plane/db/models/user.py
- apps/api/plane/api/urls/init.py
- apps/api/plane/api/urls/service_account.py
- apps/api/plane/middleware/logger.py
- apps/api/plane/utils/service_account.py
Add machine/service accounts so a self-hosted workspace can be provisioned
entirely over the API: a distinct, active actor with an API-mintable token and
no invite / email-verification / password flow.
What each account is (created in one transaction): an active, email-verified
User flagged as a bot (is_bot=True, bot_type=SERVICE) with an unusable password
and a synthetic unique username/email; a WorkspaceMember at the requested role;
and a workspace-scoped bot APIToken (user_type=Bot, is_service=True). is_bot
blocks interactive login (BOT_USER_LOGIN_FORBIDDEN); the account acts only via
its token, and its writes attribute to it via created_by.
- create_service_account management command (--workspace --name --role
[--email --description]) that prints the token.
- Optional admin-scoped POST /api/v1/workspaces/{slug}/service-accounts/
(WorkspaceOwnerPermission), mirroring the command over HTTP.
- Shared plane/utils/service_account.py helper so command and endpoint cannot
drift.
- Add SERVICE to BotTypeEnum. bot_type is a choice-less CharField, so no
migration is required.
- Security: redact secret-bearing response bodies from api_activity_logs so the
minted token is never persisted in plaintext (response-body analogue of the
existing SENSITIVE_HEADERS redaction).
- docs/service-accounts.md and contract + unit tests (token authenticates as a
distinct actor, writes attribute to it, non-admin gets 403, token not logged).
Signed-off-by: Seena Fallah <seenafallah@gmail.com>
… accounts
Let an external provisioner assign stable, readable identity to a service
account instead of the server-generated defaults, so accounts can be created
idempotently (looked up by a known username) and show a readable name in the
workspace members UI.
- POST /api/v1/workspaces/{slug}/service-accounts/ and the create_service_account
command accept optional username and display_name (--username/--display-name).
- username is globally unique and bounded only by length (max 128) — matching
regular Plane usernames, which have no charset validator (a random uuid handle).
A collision returns 409 {"code": "USERNAME_ALREADY_EXISTS"} (or a CommandError),
never a silently mutated name. A race past the pre-check is caught and mapped the
same way; any other IntegrityError is re-raised rather than mislabeled.
- display_name lands on User.display_name; both fields fall back to synthetic /
name when omitted, and a blank value normalizes to the same fallback on both
paths. The synthetic email is always a fresh unique svc_<uuid> address.
- Response echoes the effective username + display_name.
- Docs + contract/unit tests: custom identity, collision, faithful race, genuine
atomic rollback, blank normalization, and command flag round-trip.
Token semantics are unchanged (token lifecycle is a separate change).
Signed-off-by: Seena Fallah <seenafallah@gmail.com>
Add the lifecycle operations an external reconcile/rotation loop needs on top of
the atomic create. All are admin-scoped (WorkspaceOwnerPermission), follow
public-API conventions (APIKeyAuthentication, cursor pagination, OpenAPI), and
are scoped to a SERVICE bot member of the workspace.
- GET /workspaces/{slug}/service-accounts/{user_id}/tokens/ — list tokens with
metadata (label, timestamps, expiry, is_active); the secret value is always
withheld.
- POST .../tokens/ — mint an additional workspace-scoped bot token (value once,
optional expired_at).
- POST .../tokens/{token_id}/rotate/ — atomically mint a replacement (value once)
and deactivate the old token, so the old value stops authenticating.
- DELETE .../tokens/{token_id}/ — revoke (soft-delete) a token.
- DELETE /workspaces/{slug}/service-accounts/{user_id}/ — decommission:
deactivate all tokens, remove ProjectMember + WorkspaceMember rows, deactivate
the User (row kept so created_by attribution survives). Hard-guarded to
is_bot + bot_type=SERVICE; a human or other bot returns 400.
- Mint/rotate responses carry the plaintext token, so their bodies are redacted
from api_activity_logs (reuses redact_response_body from the create endpoint).
- Resolving the account intentionally does not filter membership is_active, so a
deactivated (but not decommissioned) account whose tokens still authenticate
can still be listed, revoked, and decommissioned.
- Shared mint/rotate/decommission helpers in plane/utils/service_account.py.
Tests cover: list masks values, mint/rotate return + authenticate, rotate/revoke
invalidate the old token, decommission cascade + attribution survival, the
is_bot/SERVICE guard (human + other bot -> 400), cross-workspace isolation,
non-admin denial, and mint/rotate log redaction. A module autouse fixture resets
the API-key throttle cache per test (otherwise the throttle counter is shared and
uncleared across tests). Docs updated.
Signed-off-by: Seena Fallah <seenafallah@gmail.com>
Rotation expiry semantics (CodeRabbit): - Rotating a token now INHERITS the source token's expired_at when the caller omits it, instead of minting a never-expiring replacement. An explicit null still clears the expiry; a supplied timestamp must be in the future. - The rotate body uses an OmittableDateTimeField so an empty form/multipart value reads as 'omitted' (inherit), not as null — closing a hole where a form-encoded 'expired_at=' produced an immortal token. - Rotation is restricted to ACTIVE tokens via a race-safe conditional UPDATE (409 TOKEN_NOT_ACTIVE); inheriting an already-elapsed expiry is refused (400 SOURCE_TOKEN_EXPIRY_ELAPSED). Mint and rotate reject a past expired_at. - The deactivation UPDATE writes updated_by unconditionally (None when there is no actor), matching BaseModel.save(). - Token-rotation exceptions carry code + status_code and are mapped in one place. Copilot: revoke/rotate are already scoped by construction (user_id is a SERVICE bot member of the workspace, tokens are workspace-scoped is_service bot tokens); the one real gap (rotating an inactive token) is closed above. is_service is deliberately NOT added to the revoke lookup — it would make a legacy non-service token unrevokable (fail-open). Docstring Coverage gate: document every callable in the new files (handlers, command, helpers, and all contract/unit tests). Docs: correct the rotate section (remaining-lifetime inheritance, the three expiry cases, 409 vs 404, future-timestamp requirement). Tests: rotate expiry inheritance / explicit-null / explicit-future / past rejected / form-encoded-empty-inherits / inactive-source-409 / elapsed-source-400. - Cover minting a token with no label (generated default) — CodeRabbit. - Do not conflate an omitted vs blank description: `create_service_account` now defaults only when description is None, so an explicit "" is preserved (serializer/CLI/view thread None as the "omitted" sentinel) — Copilot. - Scope decommission token deactivation to the given workspace (tokens are workspace-scoped) so retiring an account in one workspace never touches its tokens elsewhere — Copilot. - Normalize whitespace-only --email/--username/--display-name in the command (strip; blank -> None) so " " cannot create an all-whitespace identity — Copilot. - Document 401/403/404 on the create endpoint's OpenAPI schema — Copilot. - Docs: use a future expired_at in the mint example so it stays valid — Copilot. - Scope the token list/revoke/rotate queries to is_service tokens so the service-account token API only manages service tokens — Copilot. - Validate identity-field max lengths in the command (mirroring the serializer) so an over-long value fails with CommandError, not a raw DB DataError — Copilot. - resolve_service_account_role: raise the role ValueError `from None` so the user-facing message isn't buried under a KeyError chain — Copilot. - Docs: phrase the synthetic email as a default (--email can override it) and clarify that bots are hidden from the web-app member list but listed by the public members API — Copilot. - decommission token deactivation now also sets updated_at/updated_by (like rotate) via a shared _audit_fields() helper: QuerySet.update() bypasses save(), so without it a decommissioned token kept a stale updated_at and no actor attribution. Tests assert both the rotate and decommission paths stamp updated_at and attribute the deactivation to the acting admin — Copilot. Signed-off-by: Seena Fallah <seenafallah@gmail.com>
Description
Add machine/service accounts so a self-hosted workspace can be provisioned entirely over the API: a distinct, active actor with an API-mintable token and no invite / email-verification / password flow.
What each account is (created in one transaction): an active, email-verified User flagged as a bot (is_bot=True, bot_type=SERVICE) with an unusable password and a synthetic unique username/email; a WorkspaceMember at the requested role; and a workspace-scoped bot APIToken (user_type=Bot, is_service=True). is_bot blocks interactive login (BOT_USER_LOGIN_FORBIDDEN); the account acts only via its token, and its writes attribute to it via created_by.
Type of Change
Summary by CodeRabbit