Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,5 @@
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .service_account import ServiceAccountCreateSerializer, ServiceAccountSerializer
from .sticky import StickySerializer
41 changes: 41 additions & 0 deletions apps/api/plane/api/serializers/service_account.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@
*member_patterns,
*module_patterns,
*project_patterns,
*service_account_patterns,
*state_patterns,
*user_patterns,
*work_item_patterns,
Expand Down
15 changes: 15 additions & 0 deletions apps/api/plane/api/urls/service_account.py
Original file line number Diff line number Diff line change
@@ -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/<str:slug>/service-accounts/",
ServiceAccountAPIEndpoint.as_view(http_method_names=["post"]),
name="service-accounts",
),
]
2 changes: 2 additions & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,6 @@

from .invite import WorkspaceInvitationsViewset

from .service_account import ServiceAccountAPIEndpoint

from .sticky import StickyViewSet
78 changes: 78 additions & 0 deletions apps/api/plane/api/views/service_account.py
Original file line number Diff line number Diff line change
@@ -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))
76 changes: 76 additions & 0 deletions apps/api/plane/db/management/commands/create_service_account.py
Original file line number Diff line number Diff line change
@@ -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}")
1 change: 1 addition & 0 deletions apps/api/plane/db/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
28 changes: 27 additions & 1 deletion apps/api/plane/middleware/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
Loading