Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
64328f6
migration: added version field in webhook
sangeethailango Dec 15, 2025
04ec99f
chore: add max_length
sangeethailango Dec 15, 2025
b028d83
feat: add API tokens management to workspace settings
b-saikrishnakanth Dec 23, 2025
ab47d4c
chore: added product tour fields
NarayanBavisetti Dec 2, 2025
1d9390d
chore: updated the migration file
NarayanBavisetti Dec 23, 2025
e76d368
chore: removed the duplicated migration file
NarayanBavisetti Dec 23, 2025
56a41a8
chore: added allowed_rate_limit for api_tokens
sangeethailango Dec 23, 2025
10f4418
Merge branch 'preview' of github.com:makeplane/plane into migration-w…
NarayanBavisetti Dec 24, 2025
9cc3f81
chore: changed key feature tour to product tour
NarayanBavisetti Dec 24, 2025
07cf029
Merge branch 'migration-webhook' of github.com:makeplane/plane into m…
NarayanBavisetti Dec 24, 2025
0bf6e1c
feat: implement workspace-specific API token management
pablohashescobar Dec 4, 2025
42d5407
chore: separate rate limit class for workspace api token
sangeethailango Dec 19, 2025
d21f106
chore: set header
sangeethailango Dec 22, 2025
0a20a47
feat: add API tokens management to workspace settings
b-saikrishnakanth Dec 23, 2025
bc52fa1
chore: workspace api token permission check
sangeethailango Dec 23, 2025
97e18f5
fix: workspace tokens are returned in user tokens
sangeethailango Dec 24, 2025
b031dea
fix: expired_at set as a read only field
sangeethailango Dec 24, 2025
466bccc
chore: added translations
vamsikrishnamathala Dec 26, 2025
1705037
Merge branch 'feat-workspace-api-tokens' of github.com:makeplane/plan…
sangeethailango Dec 29, 2025
ae0550e
merge preview
sangeethailango Dec 29, 2025
26eba89
fix: error handling for APIToken not exist
sangeethailango Dec 29, 2025
0fd1f66
fix: error handling for APIToken not exist
sangeethailango Dec 29, 2025
a2fcc06
chore: added is_subscribed_to_changelog field
sangeethailango Dec 29, 2025
42f060b
Merge branch 'migration-webhook' into feat-workspace-api-tokens
sangeethailango Dec 30, 2025
5004073
merge preview
sangeethailango Dec 30, 2025
3a5d01c
chore: updated routes and headings
vamsikrishnamathala Dec 30, 2025
a32412e
Merge branch 'feat-workspace-api-tokens' of github.com:makeplane/plan…
vamsikrishnamathala Dec 30, 2025
fea647d
Merge branch 'preview' of github.com:makeplane/plane into feat-worksp…
vamsikrishnamathala Jan 2, 2026
d3911dc
chore: removed event capture
vamsikrishnamathala Jan 2, 2026
03e2c5a
fix: error handling
sangeethailango Jan 2, 2026
f1ae07e
chore: Resolver404 error handling
sangeethailango Jan 2, 2026
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
20 changes: 17 additions & 3 deletions apps/api/plane/api/middleware/api_authentication.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Django imports
from django.utils import timezone
from django.db.models import Q
from django.urls import resolve, Resolver404

# Third party imports
from rest_framework import authentication
from rest_framework.exceptions import AuthenticationFailed

# Module imports
from plane.db.models import APIToken
from plane.db.models import APIToken, Workspace


class APIKeyAuthentication(authentication.BaseAuthentication):
Expand All @@ -22,13 +23,21 @@ class APIKeyAuthentication(authentication.BaseAuthentication):
def get_api_token(self, request):
return request.headers.get(self.auth_header_name)

def validate_api_token(self, token):
def validate_api_token(self, token, workspace_slug):
try:
api_token = APIToken.objects.get(
Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)),
token=token,
is_active=True,
)

# If the api token has workspace_id, then check if it matches the workspace_slug
if api_token.workspace_id and workspace_slug:
workspace = Workspace.objects.get(slug=workspace_slug)

if api_token.workspace_id != workspace.id:
raise AuthenticationFailed("Given API token is not valid")

except APIToken.DoesNotExist:
raise AuthenticationFailed("Given API token is not valid")

Expand All @@ -38,10 +47,15 @@ def validate_api_token(self, token):
return (api_token.user, api_token.token)

def authenticate(self, request):
try:
workspace_slug = resolve(request.path_info).kwargs.get("slug")
except Resolver404:
workspace_slug = None

token = self.get_api_token(request=request)
if not token:
return None

# Validate the API token
user, token = self.validate_api_token(token)
user, token = self.validate_api_token(token, workspace_slug)
return user, token
39 changes: 39 additions & 0 deletions apps/api/plane/api/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

# Third party imports
from rest_framework.throttling import SimpleRateThrottle
from plane.db.models import APIToken


class ApiKeyRateThrottle(SimpleRateThrottle):
Expand Down Expand Up @@ -85,3 +86,41 @@ def allow_request(self, request, view):
request.META["X-RateLimit-Reset"] = reset_time

return allowed


class WorkspaceTokenRateThrottle(SimpleRateThrottle):
scope = "workspace_token"
rate = "60/minute"

def get_cache_key(self, request, view):
api_key = request.headers.get("X-Api-Key")
if not api_key:
return None

return f"{self.scope}:{api_key}"

def allow_request(self, request, view):
api_key = request.headers.get("X-Api-Key")

if api_key:
token = APIToken.objects.filter(token=api_key).only("allowed_rate_limit").first()
if token and token.allowed_rate_limit:
self.rate = token.allowed_rate_limit

self.num_requests, self.duration = self.parse_rate(self.rate)

allowed = super().allow_request(request, view)

if allowed:
now = self.timer()
history = self.cache.get(self.key, [])

while history and history[-1] <= now - self.duration:
history.pop()

available = self.num_requests - len(history)

request.META["X-RateLimit-Remaining"] = max(0, available)
request.META["X-RateLimit-Reset"] = int(now + self.duration)

return allowed
12 changes: 10 additions & 2 deletions apps/api/plane/api/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# Module imports
from plane.db.models.api import APIToken
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle, WorkspaceTokenRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin
Expand Down Expand Up @@ -60,12 +60,20 @@ def get_throttles(self):
api_key = self.request.headers.get("X-Api-Key")

if api_key:
service_token = APIToken.objects.filter(token=api_key, is_service=True).first()
api_token = APIToken.objects.filter(token=api_key)

service_token = api_token.filter(is_service=True).first()

workspace_token = api_token.filter(workspace_id__isnull=False).first()

if service_token:
throttle_classes.append(ServiceTokenRateThrottle())
return throttle_classes

if workspace_token:
throttle_classes.append(WorkspaceTokenRateThrottle())
return throttle_classes

throttle_classes.append(ApiKeyRateThrottle())

return throttle_classes
Expand Down
9 changes: 7 additions & 2 deletions apps/api/plane/app/serializers/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# Django import
from django.utils import timezone

# Third party import
from rest_framework import serializers

# Module import
from .base import BaseSerializer
from plane.db.models import APIToken, APIActivityLog
from rest_framework import serializers
from django.utils import timezone


class APITokenSerializer(BaseSerializer):
Expand Down
12 changes: 11 additions & 1 deletion apps/api/plane/app/urls/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.urls import path
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint

urlpatterns = [
# API Tokens
Expand All @@ -18,5 +18,15 @@
ServiceApiTokenEndpoint.as_view(),
name="service-api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/",
WorkspaceAPITokenEndpoint.as_view(),
name="workspace-api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
WorkspaceAPITokenEndpoint.as_view(),
name="workspace-api-tokens-details",
),
## End API Tokens
]
2 changes: 1 addition & 1 deletion apps/api/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@

from .module.archive import ModuleArchiveUnarchiveEndpoint

from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint
from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint, WorkspaceAPITokenEndpoint

from .page.base import (
PageViewSet,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/plane/app/views/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .base import ApiTokenEndpoint
from .service import ServiceApiTokenEndpoint
from .workspace import WorkspaceAPITokenEndpoint
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
from rest_framework import status

# Module import
from .base import BaseAPIView
from plane.db.models import APIToken, Workspace
from plane.app.views.base import BaseAPIView
from plane.db.models import APIToken
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
from plane.app.permissions import WorkspaceEntityPermission


class ApiTokenEndpoint(BaseAPIView):
Expand All @@ -37,11 +36,11 @@ def post(self, request: Request) -> Response:

def get(self, request: Request, pk: Optional[str] = None) -> Response:
if pk is None:
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
api_tokens = APIToken.objects.filter(user=request.user, is_service=False, workspace_id__isnull=True)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
api_tokens = APIToken.objects.get(user=request.user, pk=pk, workspace_id__isnull=True)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)

Expand All @@ -57,28 +56,3 @@ def patch(self, request: Request, pk: str) -> Response:
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]

def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)

api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()

if api_token:
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0

api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
37 changes: 37 additions & 0 deletions apps/api/plane/app/views/api/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Python import
from uuid import uuid4

# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status

# Module import
from .base import BaseAPIView
from plane.db.models import APIToken, Workspace
from plane.app.permissions import WorkspaceEntityPermission


class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]

def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)

api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first()

if api_token:
return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK)
else:
# Check the user type
user_type = 1 if request.user.is_bot else 0

api_token = APIToken.objects.create(
label=str(uuid4().hex),
description="Service Token",
user=request.user,
workspace=workspace,
user_type=user_type,
is_service=True,
)
return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED)
68 changes: 68 additions & 0 deletions apps/api/plane/app/views/api/workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Python import
from typing import Optional
from uuid import uuid4


# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status

# Module import
from plane.app.views import BaseAPIView
from plane.db.models import APIToken, Workspace
from plane.app.serializers import APITokenSerializer, APITokenReadSerializer
from plane.app.permissions import WorkSpaceAdminPermission


class WorkspaceAPITokenEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]

def post(self, request: Request, slug: str) -> Response:
label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "")
expired_at = request.data.get("expired_at", None)

# Check the user type
user_type = 1 if request.user.is_bot else 0

workspace = Workspace.objects.get(slug=slug)

api_token = APIToken.objects.create(
label=label,
description=description,
user=request.user,
user_type=user_type,
expired_at=expired_at,
workspace=workspace,
)

serializer = APITokenSerializer(api_token)

return Response(serializer.data, status=status.HTTP_201_CREATED)

def get(self, request: Request, slug: str, pk: Optional[str] = None) -> Response:
if pk is None:
api_tokens = APIToken.objects.filter(workspace__slug=slug, is_service=False, user=request.user)

serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
try:
api_tokens = APIToken.objects.get(workspace__slug=slug, pk=pk, user=request.user)
except APIToken.DoesNotExist:
return Response({"error": "API token does not exist"}, status=status.HTTP_404_NOT_FOUND)

serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)

def delete(self, request: Request, slug: str, pk: str) -> Response:
try:
api_token = APIToken.objects.get(workspace__slug=slug, pk=pk, is_service=False, user=request.user)
except APIToken.DoesNotExist:
return Response({"error": "API token does not exist"}, status=status.HTTP_404_NOT_FOUND)

api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
Loading
Loading