Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f5da73d
feat(ai-accounts): AI service accounts with scope enforcement on v1 A…
Liewzheng Sep 4, 2026
a57d08c
feat(ai-accounts): workspace settings UI for AI service accounts (PLA…
Liewzheng Sep 4, 2026
df6f892
feat(ai-accounts): custom bot avatar + scopes modal UI polish (PLANE-11)
Liewzheng Sep 4, 2026
32d07c3
feat(ai-accounts): wildcard 'all' scope option + equal-width compact …
Liewzheng Sep 4, 2026
2f1b074
fix(ai-accounts): make scope dropdown triggers fill equal column widt…
Liewzheng Sep 4, 2026
1a8e8bd
feat(ai-accounts): auto project membership inheritance and whoami sco…
Liewzheng Sep 4, 2026
6f84920
feat(ai-accounts): manage AI bots in workspace/project member setting…
Liewzheng Sep 4, 2026
88b787d
fix(ai-accounts): exclude inactive workspace members from add-member …
Liewzheng Sep 4, 2026
2849006
fix(ai-accounts): keep bot avatars off the uploader's USER_AVATAR cha…
Liewzheng Sep 4, 2026
15ccc36
fix(ai-accounts): harden account lifecycle and modal state handling (…
Liewzheng Sep 4, 2026
e581926
fix(ai-accounts): count admins in sole-admin check and reset modal st…
Liewzheng Sep 4, 2026
2373c63
fix(ai-accounts): guard scopes modal against reopen races (PLANE-11)
Liewzheng Sep 4, 2026
e50d2ab
fix(ai-accounts): enforce bot project membership and harden avatar/to…
Liewzheng Sep 7, 2026
a971b85
fix(i18n): add ai_accounts translations for all locales
Liewzheng Sep 8, 2026
ca8fb90
feat(ai-accounts): rotate service tokens without recreating accounts …
Liewzheng Sep 8, 2026
97b942a
feat(ai-accounts): rotate bot tokens from the workspace members row m…
Liewzheng Sep 8, 2026
a296f00
fix(i18n): add rotate-token translations for all locales
Liewzheng Sep 8, 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
3 changes: 3 additions & 0 deletions apps/api/plane/ai_accounts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
12 changes: 12 additions & 0 deletions apps/api/plane/ai_accounts/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from django.apps import AppConfig


class AIAccountsConfig(AppConfig):
name = "plane.ai_accounts"

def ready(self):
from . import signals # noqa: F401
75 changes: 75 additions & 0 deletions apps/api/plane/ai_accounts/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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.db.models import Q

# bot_type value written on the bot User rows backing AI accounts.
# Deliberately NOT added to plane.db BotTypeEnum: choices are not enforced at
# the DB level, and keeping plane/db untouched avoids migration conflicts when
# following upstream.
BOT_TYPE_AI_AGENT = "AI_AGENT"

# Predicate for member querysets: AI agent bots are treated as regular
# members (visible, removable, role-editable) while other bot types
# (e.g. WORKSPACE_SEED) stay hidden. Use as a positional filter arg.
AI_VISIBLE_MEMBER_Q = Q(member__is_bot=False) | Q(member__bot_type=BOT_TYPE_AI_AGENT)


class ResourceType:
# Wildcard: a policy row with this resource type matches any resource
ALL = "all"
PROJECT = "project"
MEMBER = "member"
USER = "user"
ASSET = "asset"
ESTIMATE = "estimate"
CYCLE = "cycle"
MODULE = "module"
STICKY = "sticky"
LABEL = "label"
INTAKE = "intake"
WORK_ITEM = "work_item"
COMMENT = "comment"
STATE = "state"
PAGE = "page"
INVITE = "invite"


RESOURCE_CHOICES = (
(ResourceType.ALL, "All"),
(ResourceType.PROJECT, "Project"),
(ResourceType.MEMBER, "Member"),
(ResourceType.USER, "User"),
(ResourceType.ASSET, "Asset"),
(ResourceType.ESTIMATE, "Estimate"),
(ResourceType.CYCLE, "Cycle"),
(ResourceType.MODULE, "Module"),
(ResourceType.STICKY, "Sticky"),
(ResourceType.LABEL, "Label"),
(ResourceType.INTAKE, "Intake"),
(ResourceType.WORK_ITEM, "Work Item"),
(ResourceType.COMMENT, "Comment"),
(ResourceType.STATE, "State"),
(ResourceType.PAGE, "Page"),
(ResourceType.INVITE, "Invite"),
)


class Action:
# Wildcard: a policy row with this action matches any action
ALL = "all"
READ = "read"
CREATE = "create"
UPDATE = "update"
DELETE = "delete"


ACTION_CHOICES = (
(Action.ALL, "All"),
(Action.READ, "Read"),
(Action.CREATE, "Create"),
(Action.UPDATE, "Update"),
(Action.DELETE, "Delete"),
)
64 changes: 64 additions & 0 deletions apps/api/plane/ai_accounts/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Generated by Django 5.2.15 on 2026-09-04 04:29

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
('db', '0122_alter_draftissue_assignees_alter_issue_assignees_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='AIAccount',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='')),
('is_active', models.BooleanField(default=True)),
('bot_user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_account', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_ai_accounts', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_accounts', to='db.workspace')),
],
options={
'verbose_name': 'AI Account',
'verbose_name_plural': 'AI Accounts',
'db_table': 'ai_accounts',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='AIScopePolicy',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('resource_type', models.CharField(choices=[('project', 'Project'), ('member', 'Member'), ('user', 'User'), ('asset', 'Asset'), ('estimate', 'Estimate'), ('cycle', 'Cycle'), ('module', 'Module'), ('sticky', 'Sticky'), ('label', 'Label'), ('intake', 'Intake'), ('work_item', 'Work Item'), ('comment', 'Comment'), ('state', 'State'), ('page', 'Page'), ('invite', 'Invite')], max_length=50)),
('action', models.CharField(choices=[('read', 'Read'), ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete')], max_length=20)),
('ai_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scope_policies', to='ai_accounts.aiaccount')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ai_scope_policies', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
],
options={
'verbose_name': 'AI Scope Policy',
'verbose_name_plural': 'AI Scope Policies',
'db_table': 'ai_scope_policies',
'ordering': ('-created_at',),
'unique_together': {('ai_account', 'project', 'resource_type', 'action', 'deleted_at')},
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-09-04 06:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('ai_accounts', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='aiscopepolicy',
name='action',
field=models.CharField(choices=[('all', 'All'), ('read', 'Read'), ('create', 'Create'), ('update', 'Update'), ('delete', 'Delete')], max_length=20),
),
migrations.AlterField(
model_name='aiscopepolicy',
name='resource_type',
field=models.CharField(choices=[('all', 'All'), ('project', 'Project'), ('member', 'Member'), ('user', 'User'), ('asset', 'Asset'), ('estimate', 'Estimate'), ('cycle', 'Cycle'), ('module', 'Module'), ('sticky', 'Sticky'), ('label', 'Label'), ('intake', 'Intake'), ('work_item', 'Work Item'), ('comment', 'Comment'), ('state', 'State'), ('page', 'Page'), ('invite', 'Invite')], max_length=50),
),
]
3 changes: 3 additions & 0 deletions apps/api/plane/ai_accounts/migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
69 changes: 69 additions & 0 deletions apps/api/plane/ai_accounts/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from django.conf import settings
from django.db import models

from plane.db.models import BaseModel

from .constants import ACTION_CHOICES, RESOURCE_CHOICES


class AIAccount(BaseModel):
"""An AI service account: a bot user acting on behalf of an owner.

The bot user (``User.is_bot=True``) can never log in interactively and only
acts through its API tokens. The owner's permissions cap everything the
account may do — the effective permission is the owner's role intersected
with the account's scope policies (enforced in ``policy.py``).
"""

workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="ai_accounts"
)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_ai_accounts"
)
bot_user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ai_account"
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
is_active = models.BooleanField(default=True)

class Meta:
verbose_name = "AI Account"
verbose_name_plural = "AI Accounts"
db_table = "ai_accounts"
ordering = ("-created_at",)

def __str__(self):
return f"{self.name} ({self.workspace.slug})"


class AIScopePolicy(BaseModel):
"""Allow-list entry: the AI account may perform ``action`` on
``resource_type`` inside ``project`` (null project = workspace-wide).

Absence of a matching row means denied (default-deny).
"""

ai_account = models.ForeignKey(
AIAccount, on_delete=models.CASCADE, related_name="scope_policies"
)
project = models.ForeignKey(
"db.Project", on_delete=models.CASCADE, null=True, blank=True, related_name="ai_scope_policies"
)
resource_type = models.CharField(max_length=50, choices=RESOURCE_CHOICES)
action = models.CharField(max_length=20, choices=ACTION_CHOICES)

class Meta:
verbose_name = "AI Scope Policy"
verbose_name_plural = "AI Scope Policies"
db_table = "ai_scope_policies"
ordering = ("-created_at",)
unique_together = ["ai_account", "project", "resource_type", "action", "deleted_at"]

def __str__(self):
return f"{self.ai_account.name}: {self.action} {self.resource_type}"
Loading