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
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