Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
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