From 3fed24191252ce4f5092258f4d894152c492c656 Mon Sep 17 00:00:00 2001 From: Seena Fallah Date: Sat, 11 Jul 2026 22:47:15 +0000 Subject: [PATCH] feat(api): add API-provisionable service accounts 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 --- apps/api/plane/api/serializers/__init__.py | 1 + .../plane/api/serializers/service_account.py | 41 +++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/service_account.py | 15 ++ apps/api/plane/api/views/__init__.py | 2 + apps/api/plane/api/views/service_account.py | 78 ++++++ .../commands/create_service_account.py | 76 ++++++ apps/api/plane/db/models/user.py | 1 + apps/api/plane/middleware/logger.py | 28 +- .../contract/api/test_service_account.py | 246 ++++++++++++++++++ .../tests/unit/middleware/test_logger.py | 27 +- .../tests/unit/utils/test_service_account.py | 31 +++ apps/api/plane/utils/service_account.py | 143 ++++++++++ docs/service-accounts.md | 126 +++++++++ 14 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 apps/api/plane/api/serializers/service_account.py create mode 100644 apps/api/plane/api/urls/service_account.py create mode 100644 apps/api/plane/api/views/service_account.py create mode 100644 apps/api/plane/db/management/commands/create_service_account.py create mode 100644 apps/api/plane/tests/contract/api/test_service_account.py create mode 100644 apps/api/plane/tests/unit/utils/test_service_account.py create mode 100644 apps/api/plane/utils/service_account.py create mode 100644 docs/service-accounts.md diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 2ab639d5466..cf38cb10f79 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -63,4 +63,5 @@ ) from .invite import WorkspaceInviteSerializer from .member import ProjectMemberSerializer +from .service_account import ServiceAccountCreateSerializer, ServiceAccountSerializer from .sticky import StickySerializer diff --git a/apps/api/plane/api/serializers/service_account.py b/apps/api/plane/api/serializers/service_account.py new file mode 100644 index 00000000000..edb4bc38e99 --- /dev/null +++ b/apps/api/plane/api/serializers/service_account.py @@ -0,0 +1,41 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.utils.service_account import DEFAULT_SERVICE_ACCOUNT_ROLE, SERVICE_ACCOUNT_ROLES + + +class ServiceAccountCreateSerializer(serializers.Serializer): + """Request body for provisioning a workspace service account.""" + + name = serializers.CharField(max_length=255, help_text="Display name for the service account") + role = serializers.ChoiceField( + choices=sorted(SERVICE_ACCOUNT_ROLES), + default=DEFAULT_SERVICE_ACCOUNT_ROLE, + help_text="Workspace role: admin, member, or guest", + ) + description = serializers.CharField( + required=False, + allow_blank=True, + default="", + help_text="Optional description stored on the API token", + ) + + +class ServiceAccountSerializer(serializers.Serializer): + """Response for a newly provisioned service account. + + ``token`` is the plaintext API key and is returned only once, at creation. + """ + + id = serializers.UUIDField(read_only=True, help_text="Service account user id") + username = serializers.CharField(read_only=True) + email = serializers.CharField(read_only=True) + display_name = serializers.CharField(read_only=True) + role = serializers.IntegerField(read_only=True, help_text="Workspace role value (20 admin, 15 member, 5 guest)") + workspace = serializers.UUIDField(read_only=True) + token = serializers.CharField(read_only=True, help_text="Plaintext API token — shown once") diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 4a202431bc7..ffeaa64ce3b 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -9,6 +9,7 @@ from .member import urlpatterns as member_patterns from .module import urlpatterns as module_patterns from .project import urlpatterns as project_patterns +from .service_account import urlpatterns as service_account_patterns from .state import urlpatterns as state_patterns from .user import urlpatterns as user_patterns from .work_item import urlpatterns as work_item_patterns @@ -23,6 +24,7 @@ *member_patterns, *module_patterns, *project_patterns, + *service_account_patterns, *state_patterns, *user_patterns, *work_item_patterns, diff --git a/apps/api/plane/api/urls/service_account.py b/apps/api/plane/api/urls/service_account.py new file mode 100644 index 00000000000..9bc59f33ce9 --- /dev/null +++ b/apps/api/plane/api/urls/service_account.py @@ -0,0 +1,15 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views import ServiceAccountAPIEndpoint + +urlpatterns = [ + path( + "workspaces//service-accounts/", + ServiceAccountAPIEndpoint.as_view(http_method_names=["post"]), + name="service-accounts", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index e8549afb437..37dd409006b 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -62,4 +62,6 @@ from .invite import WorkspaceInvitationsViewset +from .service_account import ServiceAccountAPIEndpoint + from .sticky import StickyViewSet diff --git a/apps/api/plane/api/views/service_account.py b/apps/api/plane/api/views/service_account.py new file mode 100644 index 00000000000..349afde96ec --- /dev/null +++ b/apps/api/plane/api/views/service_account.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiRequest + +# Module imports +from plane.api.views.base import BaseAPIView +from plane.api.serializers.service_account import ( + ServiceAccountCreateSerializer, + ServiceAccountSerializer, +) +from plane.db.models import Workspace +from plane.middleware.logger import redact_response_body +from plane.utils.permissions import WorkspaceOwnerPermission +from plane.utils.openapi.parameters import WORKSPACE_SLUG_PARAMETER +from plane.utils.service_account import create_service_account + + +class ServiceAccountAPIEndpoint(BaseAPIView): + """Admin-scoped endpoint for provisioning workspace service accounts. + + A workspace admin can mint a machine identity — a distinct, active actor + with its own API token — without any invite, email-verification, or + password round-trip. This is the HTTP equivalent of the + ``create_service_account`` management command. + """ + + permission_classes = [WorkspaceOwnerPermission] + + @extend_schema( + summary="Create a service account", + description=( + "Create a machine/service account in the workspace and mint an API token for it. " + "The account is active and email-verified, is added as a workspace member with the " + "given role, and can authenticate only through the returned token. The token is " + "returned once and cannot be retrieved again. Requires the caller to be a workspace admin." + ), + request=OpenApiRequest(request=ServiceAccountCreateSerializer), + parameters=[WORKSPACE_SLUG_PARAMETER], + responses={ + 201: OpenApiResponse( + description="Service account created", + response=ServiceAccountSerializer, + ) + }, + ) + def post(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + serializer = ServiceAccountCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + service_account = create_service_account( + workspace=workspace, + name=data["name"], + role=data["role"], + description=data.get("description", ""), + ) + + response = ServiceAccountSerializer( + { + "id": service_account.user.id, + "username": service_account.user.username, + "email": service_account.user.email, + "display_name": service_account.user.display_name, + "role": service_account.member.role, + "workspace": workspace.id, + "token": service_account.token, + } + ) + # The response carries the plaintext token, so keep it out of the + # api_activity_logs table (APITokenLogMiddleware logs response bodies). + return redact_response_body(Response(response.data, status=status.HTTP_201_CREATED)) diff --git a/apps/api/plane/db/management/commands/create_service_account.py b/apps/api/plane/db/management/commands/create_service_account.py new file mode 100644 index 00000000000..d934f0ea50b --- /dev/null +++ b/apps/api/plane/db/management/commands/create_service_account.py @@ -0,0 +1,76 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.core.management import BaseCommand, CommandError +from django.db import IntegrityError + +# Module imports +from plane.db.models import User, Workspace +from plane.utils.service_account import ( + DEFAULT_SERVICE_ACCOUNT_ROLE, + SERVICE_ACCOUNT_ROLES, + create_service_account, +) + + +class Command(BaseCommand): + help = ( + "Create a service (machine) account in a workspace and mint an API token for it. " + "The account is active and email-verified, needs no invite/accept flow, and can " + "authenticate only through the printed token." + ) + + def add_arguments(self, parser): + parser.add_argument("--workspace", type=str, required=True, help="Workspace slug") + parser.add_argument("--name", type=str, required=True, help="Display name for the service account") + parser.add_argument( + "--role", + type=str, + choices=list(SERVICE_ACCOUNT_ROLES), + default=DEFAULT_SERVICE_ACCOUNT_ROLE, + help=f"Workspace role (default: {DEFAULT_SERVICE_ACCOUNT_ROLE})", + ) + parser.add_argument( + "--email", + type=str, + default=None, + help="Optional email; a unique synthetic one is generated when omitted", + ) + parser.add_argument("--description", type=str, default="", help="Optional token description") + + def handle(self, *args, **options): + workspace = Workspace.objects.filter(slug=options["workspace"]).first() + if workspace is None: + raise CommandError(f"Workspace with slug '{options['workspace']}' does not exist") + + email = options.get("email") + if email and User.objects.filter(email=email).exists(): + raise CommandError(f"A user with email '{email}' already exists") + + try: + service_account = create_service_account( + workspace=workspace, + name=options["name"], + role=options["role"], + email=options["email"], + description=options["description"], + ) + except IntegrityError as exc: + # A concurrent insert (email race that slipped past the check above) + # or an extremely unlikely synthetic username/email collision surfaces + # here — report it readably instead of a raw traceback. The helper's + # @transaction.atomic has already rolled back, so no partial account + # remains. + raise CommandError(f"Could not create the service account — the email is already in use: {exc}") + + user = service_account.user + self.stdout.write(self.style.SUCCESS("Service account created successfully")) + self.stdout.write(f" user_id : {user.id}") + self.stdout.write(f" username : {user.username}") + self.stdout.write(f" email : {user.email}") + self.stdout.write(f" role : {options['role']}") + self.stdout.write(f" workspace: {workspace.slug}") + self.stdout.write(self.style.WARNING("API token (shown once — store it securely):")) + self.stdout.write(f" {service_account.token}") diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index 7f1ab162dab..bb7ab72e5e9 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -51,6 +51,7 @@ def get_default_product_tour(): class BotTypeEnum(models.TextChoices): WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed" + SERVICE = "SERVICE", "Service Account" class User(AbstractBaseUser, PermissionsMixin): diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py index 343409c4e19..99d923aa7bf 100644 --- a/apps/api/plane/middleware/logger.py +++ b/apps/api/plane/middleware/logger.py @@ -77,6 +77,25 @@ def __call__(self, request): return response +# A view can set this attribute on its response to keep the (secret-bearing) +# body out of the api_activity_logs table — the response-body analogue of +# APITokenLogMiddleware.SENSITIVE_HEADERS. Use it for endpoints that return a +# credential (e.g. a freshly minted API token) which must never be persisted in +# plaintext. +REDACT_RESPONSE_BODY_ATTR = "_plane_redact_log_body" + + +def redact_response_body(response): + """Flag a response so APITokenLogMiddleware does not persist its body. + + Returns the response so it can be used inline, e.g.:: + + return redact_response_body(Response(data, status=201)) + """ + setattr(response, REDACT_RESPONSE_BODY_ATTR, True) + return response + + class APITokenLogMiddleware: """ Middleware to log External API requests to PostgreSQL. @@ -136,6 +155,13 @@ def process_request(self, request, response, request_body): return try: + # A secret-bearing response (e.g. a newly minted API token) must not + # be persisted in plaintext — mirrors the header redaction above. + if getattr(response, REDACT_RESPONSE_BODY_ATTR, False): + response_body = "[REDACTED]" + else: + response_body = self._safe_decode_body(response.content) if response.content else None + log_data = { # Tokenize the (high-entropy) API key into a stable, non-reversible # identifier so logs can be correlated to a token without ever @@ -149,7 +175,7 @@ def process_request(self, request, response, request_body): "query_params": request.META.get("QUERY_STRING", ""), "headers": self._redacted_headers(request), "body": self._safe_decode_body(request_body) if request_body else None, - "response_body": self._safe_decode_body(response.content) if response.content else None, + "response_body": response_body, "response_code": response.status_code, "ip_address": get_client_ip(request=request), "user_agent": request.META.get("HTTP_USER_AGENT", None), diff --git a/apps/api/plane/tests/contract/api/test_service_account.py b/apps/api/plane/tests/contract/api/test_service_account.py new file mode 100644 index 00000000000..5187ba358cd --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_service_account.py @@ -0,0 +1,246 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Contract tests for workspace service (machine) accounts. + +Proves that a service account created without any invite/email/password flow is +a valid, distinct actor: it authenticates with its own API token and its writes +are attributed to it via ``created_by``. + +Covered surfaces: +- the ``create_service_account`` management command, and +- the admin-scoped ``POST /api/v1/workspaces/{slug}/service-accounts/`` endpoint. +""" + +from unittest.mock import patch + +import pytest +from django.core.management import call_command +from rest_framework import status +from rest_framework.test import APIClient + +from plane.db.models import APIToken, BotTypeEnum, Project, User, Workspace, WorkspaceMember +from plane.db.models.api import generate_token + +USERS_ME_URL = "/api/v1/users/me/" + + +def _client_for_token(token: str) -> APIClient: + client = APIClient() + client.credentials(HTTP_X_API_KEY=token) + return client + + +def _service_account_for(workspace: Workspace) -> WorkspaceMember: + return WorkspaceMember.objects.select_related("member").get( + workspace=workspace, + member__is_bot=True, + member__bot_type=BotTypeEnum.SERVICE, + ) + + +@pytest.mark.contract +class TestServiceAccountCommand: + """The management command provisions a valid, distinct, token-authing actor.""" + + @pytest.mark.django_db + def test_command_creates_active_verified_bot_member_with_token(self, workspace): + call_command( + "create_service_account", + workspace=workspace.slug, + name="CI Provisioner", + role="admin", + ) + + member = _service_account_for(workspace) + user = member.member + + # A valid actor: active + email verified, with no password round-trip. + assert user.is_active is True + assert user.is_email_verified is True + assert user.is_email_valid is True + assert user.is_bot is True + assert user.bot_type == BotTypeEnum.SERVICE + assert user.is_password_autoset is True + assert user.display_name == "CI Provisioner" + + # Added as a workspace member with the requested role. + assert member.role == 20 + assert member.is_active is True + + # A workspace-scoped bot API token was minted. + token = APIToken.objects.get(user=user) + assert token.user_type == 1 # Bot + assert token.is_service is True + assert token.workspace_id == workspace.id + assert token.token.startswith("plane_api_") + + @pytest.mark.django_db + def test_command_token_authenticates_as_distinct_actor(self, workspace): + call_command("create_service_account", workspace=workspace.slug, name="Robby", role="admin") + + user = _service_account_for(workspace).member + token = APIToken.objects.get(user=user) + + response = _client_for_token(token.token).get(USERS_ME_URL) + + assert response.status_code == status.HTTP_200_OK + # The token resolves to the service account itself — a distinct identity. + assert str(response.data["id"]) == str(user.id) + assert response.data["email"] == user.email + assert response.data["display_name"] == "Robby" + + @pytest.mark.django_db + def test_command_service_account_writes_are_attributed_to_it(self, workspace): + call_command("create_service_account", workspace=workspace.slug, name="Author Bot", role="admin") + + user = _service_account_for(workspace).member + token = APIToken.objects.get(user=user) + client = _client_for_token(token.token) + + response = client.post( + f"/api/v1/workspaces/{workspace.slug}/projects/", + {"name": "Provisioned Project", "identifier": "PROV"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.data + project = Project.objects.get(id=response.data["id"]) + # The write is attributed to the service account, not to any human. + assert project.created_by_id == user.id + + @pytest.mark.django_db + def test_command_supports_member_role(self, workspace): + call_command("create_service_account", workspace=workspace.slug, name="Member Bot", role="member") + + member = _service_account_for(workspace) + assert member.role == 15 + + @pytest.mark.django_db + def test_command_unknown_workspace_errors(self): + from django.core.management.base import CommandError + + with pytest.raises(CommandError): + call_command("create_service_account", workspace="does-not-exist", name="X", role="admin") + + @pytest.mark.django_db + def test_command_duplicate_email_errors_cleanly(self, workspace): + User.objects.create(username="taken", email="taken@plane.so") + + from django.core.management.base import CommandError + + with pytest.raises(CommandError): + call_command( + "create_service_account", + workspace=workspace.slug, + name="Dup", + role="admin", + email="taken@plane.so", + ) + + @pytest.mark.django_db + def test_command_integrity_error_becomes_command_error(self, workspace): + # A uniqueness collision that slips past the pre-check (e.g. an email + # race) surfaces as IntegrityError from the helper; the command must + # translate it into a readable CommandError, not a raw traceback. + from django.core.management.base import CommandError + from django.db import IntegrityError + + with patch( + "plane.db.management.commands.create_service_account.create_service_account", + side_effect=IntegrityError("duplicate key value violates unique constraint"), + ): + with pytest.raises(CommandError): + call_command("create_service_account", workspace=workspace.slug, name="Race", role="admin") + + +@pytest.mark.contract +class TestServiceAccountEndpoint: + """The admin-scoped HTTP endpoint mirrors the command.""" + + def _url(self, slug: str) -> str: + return f"/api/v1/workspaces/{slug}/service-accounts/" + + @pytest.mark.django_db + def test_admin_creates_service_account_and_token_works(self, api_key_client, workspace, create_user): + response = api_key_client.post( + self._url(workspace.slug), + {"name": "HTTP Bot", "role": "admin"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.data + assert response.data["role"] == 20 + assert response.data["display_name"] == "HTTP Bot" + token = response.data["token"] + assert token.startswith("plane_api_") + + # Provisioning is attributed to the acting admin (via crum current-user). + member = WorkspaceMember.objects.get(member_id=response.data["id"], workspace=workspace) + assert member.created_by_id == create_user.id + assert APIToken.objects.get(user_id=response.data["id"]).created_by_id == create_user.id + + # The returned token authenticates as the newly created distinct actor. + me = _client_for_token(token).get(USERS_ME_URL) + assert me.status_code == status.HTTP_200_OK + assert str(me.data["id"]) == str(response.data["id"]) + + # And its writes attribute to it. + created = _client_for_token(token).post( + f"/api/v1/workspaces/{workspace.slug}/projects/", + {"name": "HTTP Provisioned", "identifier": "HTTPP"}, + format="json", + ) + assert created.status_code == status.HTTP_201_CREATED, created.data + project = Project.objects.get(id=created.data["id"]) + assert str(project.created_by_id) == str(response.data["id"]) + + @pytest.mark.django_db + def test_minted_token_is_not_persisted_in_request_log(self, api_key_client, workspace): + # The admin calls this endpoint WITH their X-Api-Key, so APITokenLogMiddleware + # runs and would otherwise persist the response body (which carries the + # freshly minted token) into api_activity_logs in plaintext. The response is + # flagged for body redaction; assert the logged body never contains the token. + with patch("plane.middleware.logger.process_logs") as process_logs: + response = api_key_client.post( + self._url(workspace.slug), + {"name": "Secret Bot", "role": "admin"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED, response.data + token = response.data["token"] + assert process_logs.delay.called + log_data = process_logs.delay.call_args.kwargs["log_data"] + + assert log_data["response_body"] == "[REDACTED]" + assert token not in (log_data["response_body"] or "") + + @pytest.mark.django_db + def test_non_admin_member_is_forbidden(self, api_client, workspace): + # A guest member of the workspace is not a workspace admin. + guest = User.objects.create(username="guest_user", email="guest@plane.so") + guest.set_password("guest-pass") + guest.save() + WorkspaceMember.objects.create(workspace=workspace, member=guest, role=5, is_active=True) + guest_token = APIToken.objects.create(user=guest, label="guest-token", token=generate_token()) + + response = _client_for_token(guest_token.token).post( + self._url(workspace.slug), + {"name": "Should Fail", "role": "admin"}, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + # No service account leaked into the workspace. + assert not WorkspaceMember.objects.filter(workspace=workspace, member__bot_type=BotTypeEnum.SERVICE).exists() + + @pytest.mark.django_db + def test_unauthenticated_is_rejected(self, api_client, workspace): + response = api_client.post( + self._url(workspace.slug), + {"name": "Anon", "role": "admin"}, + format="json", + ) + assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) diff --git a/apps/api/plane/tests/unit/middleware/test_logger.py b/apps/api/plane/tests/unit/middleware/test_logger.py index 5c13f53f6f7..d9b678f4041 100644 --- a/apps/api/plane/tests/unit/middleware/test_logger.py +++ b/apps/api/plane/tests/unit/middleware/test_logger.py @@ -20,7 +20,7 @@ from django.http import HttpResponse from django.test import RequestFactory -from plane.middleware.logger import APITokenLogMiddleware +from plane.middleware.logger import APITokenLogMiddleware, redact_response_body @pytest.fixture @@ -56,9 +56,7 @@ def _captured_log_data(self, middleware, request_factory): def test_token_identifier_is_hashed_not_plaintext(self, middleware, request_factory): log_data = self._captured_log_data(middleware, request_factory) - expected_hash = hmac.new( - settings.SECRET_KEY.encode(), self.API_KEY.encode(), hashlib.sha256 - ).hexdigest() + expected_hash = hmac.new(settings.SECRET_KEY.encode(), self.API_KEY.encode(), hashlib.sha256).hexdigest() assert log_data["token_identifier"] == expected_hash assert self.API_KEY not in log_data["token_identifier"] @@ -77,3 +75,24 @@ def test_no_log_without_api_key(self, middleware, request_factory): with patch("plane.middleware.logger.process_logs") as process_logs: middleware.process_request(request, HttpResponse(b"{}"), request_body=b"") assert not process_logs.delay.called + + def test_response_body_is_logged_by_default(self, middleware, request_factory): + request = request_factory.get("/api/v1/workspaces/", HTTP_X_API_KEY=self.API_KEY) + request.user = AnonymousUser() + response = HttpResponse(b'{"ok": true}') + with patch("plane.middleware.logger.process_logs") as process_logs: + middleware.process_request(request, response, request_body=b"") + log_data = process_logs.delay.call_args.kwargs["log_data"] + assert log_data["response_body"] == '{"ok": true}' + + def test_flagged_response_body_is_redacted(self, middleware, request_factory): + # A view that returns a secret (e.g. a minted API token) flags its response + # via redact_response_body so the body is never persisted in plaintext. + request = request_factory.post("/api/v1/workspaces/x/service-accounts/", HTTP_X_API_KEY=self.API_KEY) + request.user = AnonymousUser() + response = redact_response_body(HttpResponse(b'{"token": "plane_api_supersecret"}')) + with patch("plane.middleware.logger.process_logs") as process_logs: + middleware.process_request(request, response, request_body=b"") + log_data = process_logs.delay.call_args.kwargs["log_data"] + assert log_data["response_body"] == "[REDACTED]" + assert "plane_api_supersecret" not in log_data["response_body"] diff --git a/apps/api/plane/tests/unit/utils/test_service_account.py b/apps/api/plane/tests/unit/utils/test_service_account.py new file mode 100644 index 00000000000..cb991db5af3 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_service_account.py @@ -0,0 +1,31 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Unit tests for service-account role resolution.""" + +import pytest + +from plane.utils.service_account import SERVICE_ACCOUNT_ROLES, resolve_service_account_role + + +@pytest.mark.unit +class TestResolveServiceAccountRole: + def test_valid_role_names_map_to_values(self): + assert resolve_service_account_role("admin") == 20 + assert resolve_service_account_role("member") == 15 + assert resolve_service_account_role("guest") == 5 + + def test_valid_integer_roles_pass_through(self): + for value in SERVICE_ACCOUNT_ROLES.values(): + assert resolve_service_account_role(value) == value + + def test_unknown_role_name_raises(self): + with pytest.raises(ValueError): + resolve_service_account_role("owner") + + def test_unsupported_integer_role_raises(self): + # An integer outside the allowed set (20/15/5) must not slip through and + # create a member with an invalid role. + with pytest.raises(ValueError): + resolve_service_account_role(99) diff --git a/apps/api/plane/utils/service_account.py b/apps/api/plane/utils/service_account.py new file mode 100644 index 00000000000..8b521852d68 --- /dev/null +++ b/apps/api/plane/utils/service_account.py @@ -0,0 +1,143 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Shared provisioning logic for workspace service (machine) accounts. + +A service account is a machine identity that acts *only* through an API token. +It is created directly (no invite, no email-verification, no password +round-trip) so it can be provisioned entirely programmatically, yet it is a +valid, distinct actor: it authenticates with its own token and its writes are +attributed to it via ``created_by``/``updated_by``. + +This module is the single source of truth for that flow. It is reused by both +the ``create_service_account`` management command and the admin-scoped +``/api/v1`` HTTP endpoint so the two can never drift. +""" + +# Python imports +import uuid +from dataclasses import dataclass + +# Django imports +from django.db import transaction + +# Module imports +from plane.db.models import APIToken, BotTypeEnum, User, Workspace, WorkspaceMember + +# Human-friendly role name -> WorkspaceMember.role value. +# Mirrors ROLE_CHOICES in plane/db/models/workspace.py ((20, Admin), (15, Member), (5, Guest)). +SERVICE_ACCOUNT_ROLES: dict[str, int] = {"admin": 20, "member": 15, "guest": 5} + +# Service accounts exist to provision a workspace end to end over the API, so an +# admin seat is the justified default. Narrow it with --role when a service +# account only needs member/guest scope. +DEFAULT_SERVICE_ACCOUNT_ROLE = "admin" + + +@dataclass +class ServiceAccount: + """Result of :func:`create_service_account`. + + ``token`` is the *plaintext* API key. Plane stores API tokens verbatim (the + authentication layer looks them up by exact match), so this is the value the + caller must persist — it is surfaced here exactly once at creation time. + """ + + user: User + member: WorkspaceMember + api_token: APIToken + + @property + def token(self) -> str: + return self.api_token.token + + +def resolve_service_account_role(role: str | int) -> int: + """Coerce a role name (or raw value) to a WorkspaceMember.role integer.""" + if isinstance(role, int): + if role in SERVICE_ACCOUNT_ROLES.values(): + return role + valid = ", ".join(str(v) for v in SERVICE_ACCOUNT_ROLES.values()) + raise ValueError(f"Invalid role '{role}'. Choose one of: {valid}.") + try: + return SERVICE_ACCOUNT_ROLES[role] + except KeyError: + valid = ", ".join(SERVICE_ACCOUNT_ROLES) + raise ValueError(f"Invalid role '{role}'. Choose one of: {valid}.") + + +@transaction.atomic +def create_service_account( + *, + workspace: Workspace, + name: str, + role: str | int = DEFAULT_SERVICE_ACCOUNT_ROLE, + email: str | None = None, + description: str = "", +) -> ServiceAccount: + """Create a service account in ``workspace`` and mint its API token. + + Creates, in a single transaction: + + * an ACTIVE, email-verified :class:`User` marked as a bot + (``is_bot=True``, ``bot_type=SERVICE``) with an unusable password, + * a :class:`WorkspaceMember` binding it to ``workspace`` at ``role``, + * an :class:`APIToken` (``user_type=Bot``, ``is_service=True``, + workspace-scoped) whose plaintext value is returned on the result. + + No email is sent and no password is ever round-tripped. ``is_bot=True`` + additionally blocks the interactive login/signup flow + (``BOT_USER_LOGIN_FORBIDDEN``), so the identity can be used *only* via its + token. + """ + role_value = resolve_service_account_role(role) + + # A service account never logs in, so username/email are internal, unique + # identifiers rather than human contact addresses. + unique = uuid.uuid4().hex + username = f"svc_{unique}" + if not email: + email = f"{username}@service.plane.local" + + user = User( + username=username, + email=email, + display_name=name, + first_name=name, + last_name="", + # Machine identity that acts only through API tokens (mirrors the + # WORKSPACE_SEED bot). is_bot=True forbids interactive login/signup. + is_bot=True, + bot_type=BotTypeEnum.SERVICE, + # Active + email verified so it is a valid actor with no accept flow: + # APIKeyAuthentication requires user__is_active, and downstream code + # treats a verified email as a fully onboarded account. + is_active=True, + is_email_verified=True, + is_email_valid=True, + is_password_autoset=True, + ) + # No password round-trip: the account authenticates only via its API token, + # so give it an unusable password that can never be used to log in. + user.set_unusable_password() + user.save() + + member = WorkspaceMember.objects.create( + workspace=workspace, + member=user, + role=role_value, + company_role="", + ) + + api_token = APIToken.objects.create( + label=name, + description=description or f"Service account token for {name}", + user=user, + # 1 == Bot (see APIToken.user_type choices). + user_type=1, + workspace=workspace, + is_service=True, + ) + + return ServiceAccount(user=user, member=member, api_token=api_token) diff --git a/docs/service-accounts.md b/docs/service-accounts.md new file mode 100644 index 00000000000..1d1939551c5 --- /dev/null +++ b/docs/service-accounts.md @@ -0,0 +1,126 @@ +# Service Accounts + +A **service account** is a machine identity for provisioning and automating a +workspace entirely over the API. Unlike a human member, a service account: + +- is created directly — **no invite, no email-verification, no password round-trip**; +- **cannot log in interactively** (email/password, magic code, or OAuth are all + rejected — it is a bot, see `BOT_USER_LOGIN_FORBIDDEN`); +- acts **only through its API token**; and +- is a **valid, distinct actor**: it authenticates as itself and everything it + creates is attributed to it (`created_by`). + +This makes a workspace fully provisionable by scripts and agents without a human +in the loop. + +## What gets created + +Creating a service account performs three writes in a single transaction: + +1. A `User` that is **active** (`is_active=True`) and **email-verified** + (`is_email_verified=True`, `is_email_valid=True`), flagged as a bot + (`is_bot=True`, `bot_type=SERVICE`) with an unusable password + (`is_password_autoset=True`). Its `username`/`email` are unique, synthetic + identifiers — no mail is ever sent to them. +2. A `WorkspaceMember` binding the user to the workspace at the requested role. +3. An `APIToken` (`user_type=Bot`, `is_service=True`, scoped to the workspace). + Its plaintext value is printed/returned **once** — store it securely, it + cannot be retrieved again. + +Because `is_bot=True`, the account is intentionally omitted from the human-facing +workspace **member list** (the same behaviour as the built-in workspace-seed bot). +It is still a full member for authorization and attribution: it passes permission +checks and appears as the `created_by`/`updated_by` actor on everything it writes, +and it is visible via the API (e.g. `GET /api/v1/workspaces/{slug}/members/`). + +## Roles + +| Name | Value | Capability | +| -------- | ----- | ------------------------------------------- | +| `admin` | 20 | Full workspace admin (default) | +| `member` | 15 | Create/update most entities | +| `guest` | 5 | Read-oriented, limited | + +The default is `admin`, since a service account usually needs to provision a +workspace end to end. Narrow it with the `role` argument when a token only needs +member/guest scope. + +## Creating one — management command + +```bash +python manage.py create_service_account \ + --workspace \ + --name "CI Provisioner" \ + --role admin +``` + +Arguments: + +- `--workspace` (required) — target workspace slug. +- `--name` (required) — display name for the account. +- `--role` — `admin` (default), `member`, or `guest`. +- `--email` — optional; a unique synthetic address is generated when omitted. +- `--description` — optional description stored on the token. + +The command prints the account details and the API token (shown once): + +```text +Service account created successfully + user_id : 1c2f...c7f3 + username : svc_9d89245e45164385a00e42ff6056cbce + email : svc_9d89245e45164385a00e42ff6056cbce@service.plane.local + role : admin + workspace: my-workspace +API token (shown once — store it securely): + plane_api_9d89245e45164385a00e42ff6056cbce +``` + +Use the token with the public API via the `X-Api-Key` header: + +```bash +curl -H "X-Api-Key: plane_api_..." https:///api/v1/users/me/ +``` + +## Creating one — admin HTTP endpoint (optional) + +The same flow is exposed over HTTP for callers that already hold a +**workspace-admin** API token: + +```http +POST /api/v1/workspaces/{slug}/service-accounts/ +X-Api-Key: +Content-Type: application/json + +{ "name": "CI Provisioner", "role": "admin", "description": "optional" } +``` + +Response `201 Created` (the `token` is returned once): + +```json +{ + "id": "1c2f...c7f3", + "username": "svc_...", + "email": "svc_...@service.plane.local", + "display_name": "CI Provisioner", + "role": 20, + "workspace": "8f0e...9ab1", + "token": "plane_api_..." +} +``` + +The caller must be an **admin** of `{slug}` (enforced by `WorkspaceOwnerPermission`); +any other caller receives `403 Forbidden`. This endpoint requires an existing +admin token, so the first service account in a fresh instance is typically minted +with the management command above. + +## Notes + +- Tokens are stored verbatim and matched exactly at authentication time; there is + no way to recover a lost token — mint a new account/token instead. +- The HTTP endpoint's response body is redacted from the API request log + (`api_activity_logs`), so the minted token is never persisted there in plaintext. +- Provisioning is attributed to the acting admin: the created `WorkspaceMember` + and `APIToken` carry that admin as `created_by` (the management command has no + acting user, so those rows are created with no `created_by`). +- The token authenticates only while the account stays active; deactivating the + `User` (`is_active=False`) immediately revokes access.