Compare commits

..

1 Commits

Author SHA1 Message Date
Zamil Majdy
f5e2eccda7 dx(orchestrate): fix stale-review gate and add pr-test evaluation rules to SKILL.md (#12701)
## Changes

### verify-complete.sh
- CHANGES_REQUESTED reviews are now compared against the latest commit
timestamp. If the review was submitted **before** the latest commit, it
is treated as stale and does not block verification.
- Added fail-closed guard: if the `gh pr view` fetch fails, the script
exits 1 (rather than treating missing data as "no blocking reviews")
- Fixed edge case: a `CHANGES_REQUESTED` review with a null
`submittedAt` is now counted as fresh/blocking (previously silently
skipped)
- Combined two separate `gh pr view` calls into one (`--json
commits,reviews`) to reduce API calls and ensure consistency

### SKILL.md (orchestrate skill)
- Added `### /pr-test result evaluation` section with explicit
pass/partial/fail handling table
- **PARTIAL on any headline feature scenario = immediate blocker**:
re-brief the agent, fix, and re-run from scratch. Never approve or
output ORCHESTRATOR:DONE with a PARTIAL headline result.
- Concrete incident callout: PR #12699 S5 (Apply suggestions) was
PARTIAL — AI never output JSON action blocks — but was nearly approved.
This rule prevents recurrence.
- Updated `verify-complete.sh` description throughout to include "no
fresh CHANGES_REQUESTED"
- Added staleness rule documentation: a review only blocks if submitted
*after* the latest commit

## Why

Two separate incidents prompted these changes:

1. **verify-complete.sh false positive**: An automated bot
(autogpt-pr-reviewer) submitted a `CHANGES_REQUESTED` review in April.
An agent then pushed fixing commits. The old script still blocked on the
stale review, preventing the PR from being verified as done.

2. **Missed PARTIAL signal**: PR #12699 had a PARTIAL result on its
headline scenario (S5 Apply button) because the AI emitted direct
builder tool calls instead of JSON action blocks. The orchestrator
nearly approved it. The new SKILL.md rule makes PARTIAL = blocker
explicit.

## Checklist

- [x] I have read the contribution guide
- [x] My changes follow the code style of this project  
- [x] Changes are limited to the scope of this PR (< 20% unrelated
changes)
- [x] All new and existing tests pass
2026-04-08 08:58:42 +07:00
61 changed files with 856 additions and 13817 deletions

View File

@@ -25,7 +25,7 @@ STATE_FILE=~/.claude/orchestrator-state.json
| `spawn-agent.sh SESSION PATH SPARE NEW_BRANCH OBJECTIVE [PR_NUMBER] [STEPS...]` | Create window + checkout branch + launch claude + send task. **Stdout: `SESSION:WIN` only** |
| `recycle-agent.sh WINDOW PATH SPARE_BRANCH` | Kill window + restore spare branch |
| `run-loop.sh` | **Mechanical babysitter** — idle restart + dialog approval + recycle on ORCHESTRATOR:DONE + supervisor health check + all-done notification |
| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green. Repo auto-derived from state file `.repo` or git remote. |
| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green + no fresh CHANGES_REQUESTED. Repo auto-derived from state file `.repo` or git remote. |
| `notify.sh MESSAGE` | Send notification via Discord webhook (env `DISCORD_WEBHOOK_URL` or state `.discord_webhook`), macOS notification center, and stdout |
| `capacity.sh [REPO_ROOT]` | Print available + in-use worktrees |
| `status.sh` | Print fleet status + live pane commands |
@@ -64,7 +64,7 @@ spare/N branch → spawn-agent.sh (--session-id UUID) → window + feat/bran
ORCHESTRATOR:DONE
verify-complete.sh: checkpoints ✓ + 0 threads + CI green
verify-complete.sh: checkpoints ✓ + 0 threads + CI green + no fresh CHANGES_REQUESTED
state → "done", notify, window KEPT OPEN
@@ -328,7 +328,9 @@ For each agent, decide:
### Strict ORCHESTRATOR:DONE gate
`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CHANGES_REQUESTED, CI green, spawned_at). Run it:
`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CI green, spawned_at, and CHANGES_REQUESTED). Run it:
**CHANGES_REQUESTED staleness rule**: a `CHANGES_REQUESTED` review only blocks if it was submitted *after* the latest commit. If the latest commit postdates the review, the review is considered stale (feedback already addressed) and does not block. This avoids false negatives when a bot reviewer hasn't re-reviewed after the agent's fixing commits.
```bash
SKILLS_DIR=~/.claude/orchestrator/scripts
@@ -412,6 +414,38 @@ Please verify: <specific behaviors to check>.
Only one `/pr-test` at a time — they share ports and DB.
### /pr-test result evaluation
**PARTIAL on any headline feature scenario is an immediate blocker.** Do not approve, do not mark done, do not let the agent output `ORCHESTRATOR:DONE`.
| `/pr-test` result | Action |
|---|---|
| All headline scenarios **PASS** | Proceed to evaluation step 2 |
| Any headline scenario **PARTIAL** | Re-brief the agent immediately — see below |
| Any headline scenario **FAIL** | Re-brief the agent immediately |
**What PARTIAL means**: the feature is only partly working. Example: the Apply button never appeared, or the AI returned no action blocks. The agent addressed part of the objective but not all of it.
**When any headline scenario is PARTIAL or FAIL:**
1. Do NOT mark the agent done or accept `ORCHESTRATOR:DONE`
2. Re-brief the agent with the specific scenario that failed and what was missing:
```bash
tmux send-keys -t SESSION:WIN "PARTIAL result on /pr-test — S5 (Apply button) never appeared. The AI must output JSON action blocks for the Apply button to render. Fix this before re-running /pr-test."
sleep 0.3
tmux send-keys -t SESSION:WIN Enter
```
3. Set state back to `running`:
```bash
jq --arg w "SESSION:WIN" '(.agents[] | select(.window == $w)).state = "running"' \
~/.claude/orchestrator-state.json > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json
```
4. Wait for new `ORCHESTRATOR:DONE`, then re-run `/pr-test` from scratch
**Rule: only ALL-PASS qualifies for approval.** A mix of PASS + PARTIAL is a failure.
> **Why this matters**: PR #12699 was wrongly approved with S5 PARTIAL — the AI never output JSON action blocks so the Apply button never appeared. The fix was already in the agent's reach but slipped through because PARTIAL was not treated as blocking.
### 2. Do your own evaluation
1. **Read the PR diff and objective** — does the code actually implement what was asked? Is anything obviously missing or half-done?
@@ -421,8 +455,9 @@ Only one `/pr-test` at a time — they share ports and DB.
### 3. Decide
- `/pr-test` passes + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed
- `/pr-test` fails or evaluation finds gaps → re-brief the agent with specific failures, set state back to `running`
- `/pr-test` all scenarios PASS + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed
- `/pr-test` any scenario PARTIAL or FAIL → re-brief the agent with the specific failing scenario, set state back to `running` (see `/pr-test result evaluation` above)
- Evaluation finds gaps even with all PASS → re-brief the agent with specific gaps, set state back to `running`
**Never mark done based purely on script output.** You hold the full objective context; the script does not.
@@ -441,6 +476,7 @@ Stop the fleet (`active = false`) when **all** of the following are true:
| All agents are `done` or `escalated` | `jq '[.agents[] | select(.state | test("running\|stuck\|idle\|waiting_approval"))] | length' ~/.claude/orchestrator-state.json` == 0 |
| All PRs have 0 unresolved review threads | GraphQL `isResolved` check per PR |
| All PRs have green CI **on a run triggered after the agent's last push** | `gh run list --branch BRANCH --limit 1` timestamp > `spawned_at` in state |
| No fresh CHANGES_REQUESTED (after latest commit) | `verify-complete.sh` checks this — stale pre-commit reviews are ignored |
| No agents are `escalated` without human review | If any are escalated, surface to user first |
**Do NOT stop just because agents output `ORCHESTRATOR:DONE`.** That is a signal to verify, not a signal to stop.

View File

@@ -115,13 +115,64 @@ if [ "$UNRESOLVED" -gt 0 ]; then
fi
# --- Check 6: no CHANGES_REQUESTED (checked AFTER CI — bots post reviews after their check) ---
CHANGES_REQUESTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
--json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0")
# A CHANGES_REQUESTED review is stale if the latest commit was pushed AFTER the review was submitted.
# Stale reviews (pre-dating the fixing commits) should not block verification.
#
# Fetch commits and latestReviews in a single call and fail closed — if gh fails,
# treat that as NOT COMPLETE rather than silently passing.
# Use latestReviews (not reviews) so each reviewer's latest state is used — superseded
# CHANGES_REQUESTED entries are automatically excluded when the reviewer later approved.
# Note: we intentionally use committedDate (not PR updatedAt) because updatedAt changes on any
# PR activity (bot comments, label changes) which would create false negatives.
PR_REVIEW_METADATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
--json commits,latestReviews 2>/dev/null) || {
echo "NOT COMPLETE: unable to fetch PR review metadata for PR #$PR_NUMBER" >&2
exit 1
}
if [ "$CHANGES_REQUESTED" -gt 0 ]; then
REQUESTERS=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
--json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED") | .author.login] | join(", ")' 2>/dev/null || echo "unknown")
echo "NOT COMPLETE: CHANGES_REQUESTED from ${REQUESTERS} on PR #$PR_NUMBER" >&2
LATEST_COMMIT_DATE=$(jq -r '.commits[-1].committedDate // ""' <<< "$PR_REVIEW_METADATA")
CHANGES_REQUESTED_REVIEWS=$(jq '[.latestReviews[]? | select(.state == "CHANGES_REQUESTED")]' <<< "$PR_REVIEW_METADATA")
BLOCKING_CHANGES_REQUESTED=0
BLOCKING_REQUESTERS=""
if [ -n "$LATEST_COMMIT_DATE" ] && [ "$(echo "$CHANGES_REQUESTED_REVIEWS" | jq length)" -gt 0 ]; then
if date --version >/dev/null 2>&1; then
LATEST_COMMIT_EPOCH=$(date -d "$LATEST_COMMIT_DATE" "+%s" 2>/dev/null || echo "0")
else
LATEST_COMMIT_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LATEST_COMMIT_DATE" "+%s" 2>/dev/null || echo "0")
fi
while IFS= read -r review; do
[ -z "$review" ] && continue
REVIEW_DATE=$(echo "$review" | jq -r '.submittedAt // ""')
REVIEWER=$(echo "$review" | jq -r '.author.login // "unknown"')
if [ -z "$REVIEW_DATE" ]; then
# No submission date — treat as fresh (conservative: blocks verification)
BLOCKING_CHANGES_REQUESTED=$(( BLOCKING_CHANGES_REQUESTED + 1 ))
BLOCKING_REQUESTERS="${BLOCKING_REQUESTERS:+$BLOCKING_REQUESTERS, }${REVIEWER}"
else
if date --version >/dev/null 2>&1; then
REVIEW_EPOCH=$(date -d "$REVIEW_DATE" "+%s" 2>/dev/null || echo "0")
else
REVIEW_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$REVIEW_DATE" "+%s" 2>/dev/null || echo "0")
fi
if [ "$REVIEW_EPOCH" -gt "$LATEST_COMMIT_EPOCH" ]; then
# Review was submitted AFTER latest commit — still fresh, blocks verification
BLOCKING_CHANGES_REQUESTED=$(( BLOCKING_CHANGES_REQUESTED + 1 ))
BLOCKING_REQUESTERS="${BLOCKING_REQUESTERS:+$BLOCKING_REQUESTERS, }${REVIEWER}"
fi
# Review submitted BEFORE latest commit — stale, skip
fi
done <<< "$(echo "$CHANGES_REQUESTED_REVIEWS" | jq -c '.[]')"
else
# No commit date or no changes_requested — check raw count as fallback
BLOCKING_CHANGES_REQUESTED=$(echo "$CHANGES_REQUESTED_REVIEWS" | jq length 2>/dev/null || echo "0")
BLOCKING_REQUESTERS=$(echo "$CHANGES_REQUESTED_REVIEWS" | jq -r '[.[].author.login] | join(", ")' 2>/dev/null || echo "unknown")
fi
if [ "$BLOCKING_CHANGES_REQUESTED" -gt 0 ]; then
echo "NOT COMPLETE: CHANGES_REQUESTED (after latest commit) from ${BLOCKING_REQUESTERS} on PR #$PR_NUMBER" >&2
exit 1
fi

View File

@@ -1,21 +1,12 @@
from .config import verify_settings
from .dependencies import (
get_optional_user_id,
get_request_context,
get_user_id,
requires_admin_user,
requires_org_permission,
requires_team_permission,
requires_user,
)
from .helpers import add_auth_responses_to_openapi
from .models import RequestContext, User
from .permissions import (
OrgAction,
TeamAction,
check_org_permission,
check_team_permission,
)
from .models import User
__all__ = [
"verify_settings",
@@ -23,14 +14,6 @@ __all__ = [
"requires_admin_user",
"requires_user",
"get_optional_user_id",
"get_request_context",
"requires_org_permission",
"requires_team_permission",
"add_auth_responses_to_openapi",
"User",
"RequestContext",
"OrgAction",
"TeamAction",
"check_org_permission",
"check_team_permission",
]

View File

@@ -10,13 +10,7 @@ import fastapi
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .jwt_utils import get_jwt_payload, verify_user
from .models import RequestContext, User
from .permissions import (
OrgAction,
TeamAction,
check_org_permission,
check_team_permission,
)
from .models import User
optional_bearer = HTTPBearer(auto_error=False)
@@ -121,210 +115,3 @@ async def get_user_id(
return impersonate_header
return user_id
# ---------------------------------------------------------------------------
# Org / Workspace context resolution
# ---------------------------------------------------------------------------
ORG_HEADER_NAME = "X-Org-Id"
TEAM_HEADER_NAME = "X-Team-Id"
async def get_request_context(
request: fastapi.Request,
jwt_payload: dict = fastapi.Security(get_jwt_payload),
) -> RequestContext:
"""
FastAPI dependency that resolves the full org/workspace context for a request.
Resolution order:
1. Extract user_id from JWT (supports admin impersonation via X-Act-As-User-Id).
2. Read X-Org-Id header; fall back to the user's personal org; fail if none.
3. Validate that the user has an ACTIVE OrgMember row for that org.
4. Read X-Team-Id header (optional). If set, validate that the
workspace belongs to the org AND the user has an TeamMember
row. On failure, silently fall back to None (org-home).
5. Populate all role flags and return a RequestContext.
"""
from backend.data.db import prisma # deferred -- only needed at runtime
# --- 1. user_id (reuse existing impersonation logic) ----------------------
user_id = jwt_payload.get("sub")
if not user_id:
raise fastapi.HTTPException(
status_code=401, detail="User ID not found in token"
)
impersonate_header = request.headers.get(IMPERSONATION_HEADER_NAME, "").strip()
if impersonate_header:
authenticated_user = verify_user(jwt_payload, admin_only=False)
if authenticated_user.role != "admin":
raise fastapi.HTTPException(
status_code=403,
detail="Only admin users can impersonate other users",
)
logger.info(
f"Admin impersonation: {authenticated_user.user_id} ({authenticated_user.email}) "
f"acting as user {impersonate_header} for requesting {request.method} {request.url}"
)
user_id = impersonate_header
# --- 2. org_id ------------------------------------------------------------
org_id = request.headers.get(ORG_HEADER_NAME, "").strip() or None
if org_id is None:
# Fall back to the user's personal org (an org where the user is the
# sole owner, typically created at sign-up).
personal_org = await prisma.orgmember.find_first(
where={
"userId": user_id,
"isOwner": True,
"Org": {"isPersonal": True},
},
order={"createdAt": "asc"},
)
if personal_org is None:
logger.warning(
f"User {user_id} has no personal org — account in inconsistent state"
)
raise fastapi.HTTPException(
status_code=400,
detail=(
"No organization context available. Your account may be in "
"an inconsistent state — please contact support."
),
)
org_id = personal_org.orgId
# --- 3. validate OrgMember ------------------------------------------------
org_member = await prisma.orgmember.find_unique(
where={
"orgId_userId": {"orgId": org_id, "userId": user_id},
},
)
if org_member is None or org_member.status != "ACTIVE":
raise fastapi.HTTPException(
status_code=403,
detail="User is not an active member of this organization",
)
is_org_owner = org_member.isOwner
is_org_admin = org_member.isAdmin
is_org_billing_manager = org_member.isBillingManager
seat_status = "ACTIVE" # validated above; seat assignment checked separately
# --- 4. team_id (optional) -------------------------------------------
team_id: str | None = (
request.headers.get(TEAM_HEADER_NAME, "").strip() or None
)
is_team_admin = False
is_team_billing_manager = False
if team_id is not None:
# Validate workspace belongs to org AND user has a membership row
ws_member = await prisma.teammember.find_unique(
where={
"teamId_userId": {
"teamId": team_id,
"userId": user_id,
},
},
include={"Team": True},
)
if (
ws_member is None
or ws_member.Team is None
or ws_member.Team.orgId != org_id
):
logger.debug(
"Workspace %s not valid for user %s in org %s; falling back to org-home",
team_id,
user_id,
org_id,
)
team_id = None
else:
is_team_admin = ws_member.isAdmin
is_team_billing_manager = ws_member.isBillingManager
# --- 5. build context -----------------------------------------------------
return RequestContext(
user_id=user_id,
org_id=org_id,
team_id=team_id,
is_org_owner=is_org_owner,
is_org_admin=is_org_admin,
is_org_billing_manager=is_org_billing_manager,
is_team_admin=is_team_admin,
is_team_billing_manager=is_team_billing_manager,
seat_status=seat_status,
)
def requires_org_permission(
*actions: OrgAction,
):
"""Factory returning a FastAPI dependency that enforces org-level permissions.
The request is allowed only if the user holds **all** listed actions.
Example::
@router.delete("/org/{org_id}")
async def delete_org(
ctx: RequestContext = Security(requires_org_permission(OrgAction.DELETE_ORG)),
):
...
"""
async def _dependency(
ctx: RequestContext = fastapi.Security(get_request_context),
) -> RequestContext:
for action in actions:
if not check_org_permission(ctx, action):
raise fastapi.HTTPException(
status_code=403,
detail=f"Missing org permission: {action.value}",
)
return ctx
return _dependency
def requires_team_permission(
*actions: TeamAction,
):
"""Factory returning a FastAPI dependency that enforces workspace-level permissions.
The user must be in a workspace context (team_id is set) and
hold **all** listed actions.
Example::
@router.post("/workspace/{ws_id}/agents")
async def create_agent(
ctx: RequestContext = Security(
requires_team_permission(TeamAction.CREATE_AGENTS)
),
):
...
"""
async def _dependency(
ctx: RequestContext = fastapi.Security(get_request_context),
) -> RequestContext:
if ctx.team_id is None:
raise fastapi.HTTPException(
status_code=400,
detail="Workspace context required for this action",
)
for action in actions:
if not check_team_permission(ctx, action):
raise fastapi.HTTPException(
status_code=403,
detail=f"Missing workspace permission: {action.value}",
)
return ctx
return _dependency

View File

@@ -20,16 +20,3 @@ class User:
phone_number=payload.get("phone", ""),
role=payload["role"],
)
@dataclass(frozen=True)
class RequestContext:
user_id: str
org_id: str
team_id: str | None # None = org-home context
is_org_owner: bool
is_org_admin: bool
is_org_billing_manager: bool
is_team_admin: bool
is_team_billing_manager: bool
seat_status: str # ACTIVE, INACTIVE, PENDING, NONE

View File

@@ -1,115 +0,0 @@
"""
Role-based permission checks for org-level and workspace-level actions.
Permission maps are pure data; check functions are pure functions operating
on a RequestContext -- no I/O, no database access.
"""
from enum import Enum
from .models import RequestContext
class OrgAction(str, Enum):
DELETE_ORG = "DELETE_ORG"
RENAME_ORG = "RENAME_ORG"
MANAGE_MEMBERS = "MANAGE_MEMBERS"
MANAGE_WORKSPACES = "MANAGE_WORKSPACES"
CREATE_WORKSPACES = "CREATE_WORKSPACES"
MANAGE_BILLING = "MANAGE_BILLING"
PUBLISH_TO_STORE = "PUBLISH_TO_STORE"
TRANSFER_RESOURCES = "TRANSFER_RESOURCES"
VIEW_ORG = "VIEW_ORG"
CREATE_RESOURCES = "CREATE_RESOURCES"
SHARE_RESOURCES = "SHARE_RESOURCES"
class TeamAction(str, Enum):
MANAGE_MEMBERS = "MANAGE_MEMBERS"
MANAGE_SETTINGS = "MANAGE_SETTINGS"
MANAGE_CREDENTIALS = "MANAGE_CREDENTIALS"
VIEW_SPEND = "VIEW_SPEND"
CREATE_AGENTS = "CREATE_AGENTS"
USE_CREDENTIALS = "USE_CREDENTIALS"
VIEW_EXECUTIONS = "VIEW_EXECUTIONS"
DELETE_AGENTS = "DELETE_AGENTS"
# Org permission map: action -> set of roles that are allowed.
# Roles checked via RequestContext boolean flags.
# "member" means any active org member (no special role flag required).
_ORG_PERMISSIONS: dict[OrgAction, set[str]] = {
OrgAction.DELETE_ORG: {"owner"},
OrgAction.RENAME_ORG: {"owner", "admin"},
OrgAction.MANAGE_MEMBERS: {"owner", "admin"},
OrgAction.MANAGE_WORKSPACES: {"owner", "admin"},
OrgAction.CREATE_WORKSPACES: {"owner", "admin", "billing_manager"},
OrgAction.MANAGE_BILLING: {"owner", "billing_manager"},
OrgAction.PUBLISH_TO_STORE: {"owner", "admin", "member"},
OrgAction.TRANSFER_RESOURCES: {"owner", "admin"},
OrgAction.VIEW_ORG: {"owner", "admin", "billing_manager", "member"},
OrgAction.CREATE_RESOURCES: {"owner", "admin", "member"},
OrgAction.SHARE_RESOURCES: {"owner", "admin", "member"},
}
# Workspace permission map: action -> set of roles that are allowed.
# "team_member" means any workspace member with no special workspace role.
_TEAM_PERMISSIONS: dict[TeamAction, set[str]] = {
TeamAction.MANAGE_MEMBERS: {"team_admin"},
TeamAction.MANAGE_SETTINGS: {"team_admin"},
TeamAction.MANAGE_CREDENTIALS: {"team_admin"},
TeamAction.VIEW_SPEND: {"team_admin", "team_billing_manager"},
TeamAction.CREATE_AGENTS: {"team_admin", "team_member"},
TeamAction.USE_CREDENTIALS: {"team_admin", "team_member"},
TeamAction.VIEW_EXECUTIONS: {"team_admin", "team_member"},
TeamAction.DELETE_AGENTS: {"team_admin"},
}
def _get_org_roles(ctx: RequestContext) -> set[str]:
"""Derive the set of org-level role tags from a RequestContext."""
roles: set[str] = set()
if ctx.is_org_owner:
roles.add("owner")
if ctx.is_org_admin:
roles.add("admin")
if ctx.is_org_billing_manager:
roles.add("billing_manager")
# A plain member (no owner/admin/billing_manager flags) gets "member".
# Owner and admin also get "member" since they can do everything a member can.
# Billing managers do NOT get "member" — they only get finance-related actions.
if ctx.is_org_owner or ctx.is_org_admin:
roles.add("member")
elif not ctx.is_org_billing_manager:
# Plain member with no special role flags
roles.add("member")
return roles
def _get_team_roles(ctx: RequestContext) -> set[str]:
"""Derive the set of workspace-level role tags from a RequestContext."""
roles: set[str] = set()
if ctx.is_team_admin:
roles.add("team_admin")
if ctx.is_team_billing_manager:
roles.add("team_billing_manager")
# Regular workspace members (not admin, not billing_manager) get team_member.
# WS admins also get team_member (they can do everything a member can).
if ctx.team_id is not None:
if ctx.is_team_admin or (not ctx.is_team_billing_manager):
roles.add("team_member")
return roles
def check_org_permission(ctx: RequestContext, action: OrgAction) -> bool:
"""Return True if the RequestContext grants the given org-level action."""
allowed_roles = _ORG_PERMISSIONS.get(action, set())
return bool(_get_org_roles(ctx) & allowed_roles)
def check_team_permission(ctx: RequestContext, action: TeamAction) -> bool:
"""Return True if the RequestContext grants the given workspace-level action."""
if ctx.team_id is None:
return False
allowed_roles = _TEAM_PERMISSIONS.get(action, set())
return bool(_get_team_roles(ctx) & allowed_roles)

View File

@@ -1,262 +0,0 @@
"""
Exhaustive tests for org-level and workspace-level permission checks.
Every OrgAction x role combination and every TeamAction x role
combination is covered. These are pure-function tests -- no mocking,
no database, no I/O.
"""
import pytest
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import (
OrgAction,
TeamAction,
check_org_permission,
check_team_permission,
)
# ---------------------------------------------------------------------------
# Helpers to build RequestContext fixtures for each role
# ---------------------------------------------------------------------------
def _make_ctx(
*,
is_org_owner: bool = False,
is_org_admin: bool = False,
is_org_billing_manager: bool = False,
is_team_admin: bool = False,
is_team_billing_manager: bool = False,
seat_status: str = "ACTIVE",
team_id: str | None = None,
) -> RequestContext:
return RequestContext(
user_id="user-1",
org_id="org-1",
team_id=team_id,
is_org_owner=is_org_owner,
is_org_admin=is_org_admin,
is_org_billing_manager=is_org_billing_manager,
is_team_admin=is_team_admin,
is_team_billing_manager=is_team_billing_manager,
seat_status=seat_status,
)
# Convenience contexts for org-level roles
ORG_OWNER = _make_ctx(is_org_owner=True)
ORG_ADMIN = _make_ctx(is_org_admin=True)
ORG_BILLING_MANAGER = _make_ctx(is_org_billing_manager=True)
ORG_MEMBER = _make_ctx() # ACTIVE seat, no special role flags
# Convenience contexts for workspace-level roles (team_id is set)
TEAM_ADMIN = _make_ctx(team_id="ws-1", is_team_admin=True)
TEAM_BILLING_MGR = _make_ctx(team_id="ws-1", is_team_billing_manager=True)
TEAM_MEMBER = _make_ctx(team_id="ws-1") # regular workspace member
# ---------------------------------------------------------------------------
# Org permission matrix
# ---------------------------------------------------------------------------
# Expected outcomes per (action, role). True = allowed.
_ORG_EXPECTED: dict[OrgAction, dict[str, bool]] = {
OrgAction.DELETE_ORG: {
"owner": True,
"admin": False,
"billing_manager": False,
"member": False,
},
OrgAction.RENAME_ORG: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": False,
},
OrgAction.MANAGE_MEMBERS: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": False,
},
OrgAction.MANAGE_WORKSPACES: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": False,
},
OrgAction.CREATE_WORKSPACES: {
"owner": True,
"admin": True,
"billing_manager": True,
"member": False,
},
OrgAction.MANAGE_BILLING: {
"owner": True,
"admin": False,
"billing_manager": True,
"member": False,
},
OrgAction.PUBLISH_TO_STORE: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": True,
},
OrgAction.TRANSFER_RESOURCES: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": False,
},
OrgAction.VIEW_ORG: {
"owner": True,
"admin": True,
"billing_manager": True,
"member": True,
},
OrgAction.CREATE_RESOURCES: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": True,
},
OrgAction.SHARE_RESOURCES: {
"owner": True,
"admin": True,
"billing_manager": False,
"member": True,
},
}
_ORG_ROLE_CTX = {
"owner": ORG_OWNER,
"admin": ORG_ADMIN,
"billing_manager": ORG_BILLING_MANAGER,
"member": ORG_MEMBER,
}
class TestOrgPermissions:
"""Exhaustive org action x role matrix."""
@pytest.mark.parametrize(
"action",
list(OrgAction),
ids=[a.value for a in OrgAction],
)
@pytest.mark.parametrize(
"role",
["owner", "admin", "billing_manager", "member"],
)
def test_org_permission_matrix(self, action: OrgAction, role: str):
ctx = _ORG_ROLE_CTX[role]
expected = _ORG_EXPECTED[action][role]
result = check_org_permission(ctx, action)
assert result is expected, (
f"OrgAction.{action.value} for role={role}: "
f"expected {expected}, got {result}"
)
def test_member_role_always_present_regardless_of_seat(self):
"""The 'member' role is implicit for all org members.
Seat-gating is enforced at the endpoint level, not in permission checks."""
ctx = _make_ctx(seat_status="INACTIVE")
# VIEW_ORG is allowed for members regardless of seat status
assert check_org_permission(ctx, OrgAction.VIEW_ORG) is True
def test_owner_with_inactive_seat_retains_all_owner_permissions(self):
"""Owner flag is independent of seat_status."""
ctx = _make_ctx(is_org_owner=True, seat_status="INACTIVE")
assert check_org_permission(ctx, OrgAction.DELETE_ORG) is True
assert check_org_permission(ctx, OrgAction.PUBLISH_TO_STORE) is True
# ---------------------------------------------------------------------------
# Workspace permission matrix
# ---------------------------------------------------------------------------
_TEAM_EXPECTED: dict[TeamAction, dict[str, bool]] = {
TeamAction.MANAGE_MEMBERS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": False,
},
TeamAction.MANAGE_SETTINGS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": False,
},
TeamAction.MANAGE_CREDENTIALS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": False,
},
TeamAction.VIEW_SPEND: {
"team_admin": True,
"team_billing_manager": True,
"team_member": False,
},
TeamAction.CREATE_AGENTS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": True,
},
TeamAction.USE_CREDENTIALS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": True,
},
TeamAction.VIEW_EXECUTIONS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": True,
},
TeamAction.DELETE_AGENTS: {
"team_admin": True,
"team_billing_manager": False,
"team_member": False,
},
}
_TEAM_ROLE_CTX = {
"team_admin": TEAM_ADMIN,
"team_billing_manager": TEAM_BILLING_MGR,
"team_member": TEAM_MEMBER,
}
class TestTeamPermissions:
"""Exhaustive workspace action x role matrix."""
@pytest.mark.parametrize(
"action",
list(TeamAction),
ids=[a.value for a in TeamAction],
)
@pytest.mark.parametrize(
"role",
["team_admin", "team_billing_manager", "team_member"],
)
def test_team_permission_matrix(self, action: TeamAction, role: str):
ctx = _TEAM_ROLE_CTX[role]
expected = _TEAM_EXPECTED[action][role]
result = check_team_permission(ctx, action)
assert result is expected, (
f"TeamAction.{action.value} for role={role}: "
f"expected {expected}, got {result}"
)
def test_no_team_context_denies_all(self):
"""Without a team_id, all workspace actions are denied."""
ctx = _make_ctx(is_team_admin=True) # no team_id
for action in TeamAction:
assert check_team_permission(ctx, action) is False
def test_team_billing_manager_is_not_team_member(self):
"""A workspace billing manager should NOT get team_member permissions."""
ctx = TEAM_BILLING_MGR
assert check_team_permission(ctx, TeamAction.CREATE_AGENTS) is False
assert check_team_permission(ctx, TeamAction.VIEW_SPEND) is True

View File

@@ -147,18 +147,11 @@ async def execute_graph(
),
) -> dict[str, Any]:
try:
# Resolve org/team from user's default (PR14 will add key-level org context)
from backend.api.features.orgs.db import get_user_default_team
org_id, team_id = await get_user_default_team(auth.user_id)
graph_exec = await add_graph_execution(
graph_id=graph_id,
user_id=auth.user_id,
inputs=node_input,
graph_version=graph_version,
organization_id=org_id,
team_id=team_id,
)
return {"id": graph_exec.id}
except Exception as e:

View File

@@ -723,7 +723,6 @@ async def stream_chat_post(
session_id: str,
request: StreamChatRequest,
user_id: str = Security(auth.get_user_id),
ctx: auth.RequestContext = Security(auth.get_request_context),
):
"""
Stream chat responses for a session (POST with context support).
@@ -867,8 +866,6 @@ async def stream_chat_post(
is_user_message=request.is_user_message,
context=request.context,
file_ids=sanitized_file_ids,
organization_id=ctx.org_id,
team_id=ctx.team_id,
mode=request.mode,
)

View File

@@ -0,0 +1,375 @@
import asyncio
import logging
from typing import Any, List
import autogpt_libs.auth as autogpt_auth_lib
from fastapi import APIRouter, HTTPException, Query, Security, status
from prisma.enums import ReviewStatus
from backend.copilot.constants import (
is_copilot_synthetic_id,
parse_node_id_from_exec_id,
)
from backend.data.execution import (
ExecutionContext,
ExecutionStatus,
get_graph_execution_meta,
get_node_executions,
)
from backend.data.graph import get_graph_settings
from backend.data.human_review import (
create_auto_approval_record,
get_pending_reviews_for_execution,
get_pending_reviews_for_user,
get_reviews_by_node_exec_ids,
has_pending_reviews_for_graph_exec,
process_all_reviews_for_execution,
)
from backend.data.model import USER_TIMEZONE_NOT_SET
from backend.data.user import get_user_by_id
from backend.data.workspace import get_or_create_workspace
from backend.executor.utils import add_graph_execution
from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse
logger = logging.getLogger(__name__)
router = APIRouter(
tags=["v2", "executions", "review"],
dependencies=[Security(autogpt_auth_lib.requires_user)],
)
async def _resolve_node_ids(
node_exec_ids: list[str],
graph_exec_id: str,
is_copilot: bool,
) -> dict[str, str]:
"""Resolve node_exec_id -> node_id for auto-approval records.
CoPilot synthetic IDs encode node_id in the format "{node_id}:{random}".
Graph executions look up node_id from NodeExecution records.
"""
if not node_exec_ids:
return {}
if is_copilot:
return {neid: parse_node_id_from_exec_id(neid) for neid in node_exec_ids}
node_execs = await get_node_executions(
graph_exec_id=graph_exec_id, include_exec_data=False
)
node_exec_map = {ne.node_exec_id: ne.node_id for ne in node_execs}
result = {}
for neid in node_exec_ids:
if neid in node_exec_map:
result[neid] = node_exec_map[neid]
else:
logger.error(
f"Failed to resolve node_id for {neid}: Node execution not found."
)
return result
@router.get(
"/pending",
summary="Get Pending Reviews",
response_model=List[PendingHumanReviewModel],
responses={
200: {"description": "List of pending reviews"},
500: {"description": "Server error", "content": {"application/json": {}}},
},
)
async def list_pending_reviews(
user_id: str = Security(autogpt_auth_lib.get_user_id),
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(25, ge=1, le=100, description="Number of reviews per page"),
) -> List[PendingHumanReviewModel]:
"""Get all pending reviews for the current user.
Retrieves all reviews with status "WAITING" that belong to the authenticated user.
Results are ordered by creation time (newest first).
Args:
user_id: Authenticated user ID from security dependency
Returns:
List of pending review objects with status converted to typed literals
Raises:
HTTPException: If authentication fails or database error occurs
Note:
Reviews with invalid status values are logged as warnings but excluded
from results rather than failing the entire request.
"""
return await get_pending_reviews_for_user(user_id, page, page_size)
@router.get(
"/execution/{graph_exec_id}",
summary="Get Pending Reviews for Execution",
response_model=List[PendingHumanReviewModel],
responses={
200: {"description": "List of pending reviews for the execution"},
404: {"description": "Graph execution not found"},
500: {"description": "Server error", "content": {"application/json": {}}},
},
)
async def list_pending_reviews_for_execution(
graph_exec_id: str,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> List[PendingHumanReviewModel]:
"""Get all pending reviews for a specific graph execution.
Retrieves all reviews with status "WAITING" for the specified graph execution
that belong to the authenticated user. Results are ordered by creation time
(oldest first) to preserve review order within the execution.
Args:
graph_exec_id: ID of the graph execution to get reviews for
user_id: Authenticated user ID from security dependency
Returns:
List of pending review objects for the specified execution
Raises:
HTTPException:
- 404: If the graph execution doesn't exist or isn't owned by this user
- 500: If authentication fails or database error occurs
Note:
Only returns reviews owned by the authenticated user for security.
Reviews with invalid status are excluded with warning logs.
"""
# Verify user owns the graph execution before returning reviews
# (CoPilot synthetic IDs don't have graph execution records)
if not is_copilot_synthetic_id(graph_exec_id):
graph_exec = await get_graph_execution_meta(
user_id=user_id, execution_id=graph_exec_id
)
if not graph_exec:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Graph execution #{graph_exec_id} not found",
)
return await get_pending_reviews_for_execution(graph_exec_id, user_id)
@router.post("/action", response_model=ReviewResponse)
async def process_review_action(
request: ReviewRequest,
user_id: str = Security(autogpt_auth_lib.get_user_id),
) -> ReviewResponse:
"""Process reviews with approve or reject actions."""
# Collect all node exec IDs from the request
all_request_node_ids = {review.node_exec_id for review in request.reviews}
if not all_request_node_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one review must be provided",
)
# Batch fetch all requested reviews (regardless of status for idempotent handling)
reviews_map = await get_reviews_by_node_exec_ids(
list(all_request_node_ids), user_id
)
# Validate all reviews were found (must exist, any status is OK for now)
missing_ids = all_request_node_ids - set(reviews_map.keys())
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Review(s) not found: {', '.join(missing_ids)}",
)
# Validate all reviews belong to the same execution
graph_exec_ids = {review.graph_exec_id for review in reviews_map.values()}
if len(graph_exec_ids) > 1:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="All reviews in a single request must belong to the same execution.",
)
graph_exec_id = next(iter(graph_exec_ids))
is_copilot = is_copilot_synthetic_id(graph_exec_id)
# Validate execution status for graph executions (skip for CoPilot synthetic IDs)
if not is_copilot:
graph_exec_meta = await get_graph_execution_meta(
user_id=user_id, execution_id=graph_exec_id
)
if not graph_exec_meta:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Graph execution #{graph_exec_id} not found",
)
if graph_exec_meta.status not in (
ExecutionStatus.REVIEW,
ExecutionStatus.INCOMPLETE,
):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Cannot process reviews while execution status is {graph_exec_meta.status}",
)
# Build review decisions map and track which reviews requested auto-approval
# Auto-approved reviews use original data (no modifications allowed)
review_decisions = {}
auto_approve_requests = {} # Map node_exec_id -> auto_approve_future flag
for review in request.reviews:
review_status = (
ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED
)
# If this review requested auto-approval, don't allow data modifications
reviewed_data = None if review.auto_approve_future else review.reviewed_data
review_decisions[review.node_exec_id] = (
review_status,
reviewed_data,
review.message,
)
auto_approve_requests[review.node_exec_id] = review.auto_approve_future
# Process all reviews
updated_reviews = await process_all_reviews_for_execution(
user_id=user_id,
review_decisions=review_decisions,
)
# Create auto-approval records for approved reviews that requested it
# Deduplicate by node_id to avoid race conditions when multiple reviews
# for the same node are processed in parallel
async def create_auto_approval_for_node(
node_id: str, review_result
) -> tuple[str, bool]:
"""
Create auto-approval record for a node.
Returns (node_id, success) tuple for tracking failures.
"""
try:
await create_auto_approval_record(
user_id=user_id,
graph_exec_id=review_result.graph_exec_id,
graph_id=review_result.graph_id,
graph_version=review_result.graph_version,
node_id=node_id,
payload=review_result.payload,
)
return (node_id, True)
except Exception as e:
logger.error(
f"Failed to create auto-approval record for node {node_id}",
exc_info=e,
)
return (node_id, False)
# Collect node_exec_ids that need auto-approval and resolve their node_ids
node_exec_ids_needing_auto_approval = [
node_exec_id
for node_exec_id, review_result in updated_reviews.items()
if review_result.status == ReviewStatus.APPROVED
and auto_approve_requests.get(node_exec_id, False)
]
node_id_map = await _resolve_node_ids(
node_exec_ids_needing_auto_approval, graph_exec_id, is_copilot
)
# Deduplicate by node_id — one auto-approval per node
nodes_needing_auto_approval: dict[str, Any] = {}
for node_exec_id in node_exec_ids_needing_auto_approval:
node_id = node_id_map.get(node_exec_id)
if node_id and node_id not in nodes_needing_auto_approval:
nodes_needing_auto_approval[node_id] = updated_reviews[node_exec_id]
# Execute all auto-approval creations in parallel (deduplicated by node_id)
auto_approval_results = await asyncio.gather(
*[
create_auto_approval_for_node(node_id, review_result)
for node_id, review_result in nodes_needing_auto_approval.items()
],
return_exceptions=True,
)
# Count auto-approval failures
auto_approval_failed_count = 0
for result in auto_approval_results:
if isinstance(result, Exception):
auto_approval_failed_count += 1
logger.error(
f"Unexpected exception during auto-approval creation: {result}"
)
elif isinstance(result, tuple) and len(result) == 2 and not result[1]:
auto_approval_failed_count += 1
# Count results
approved_count = sum(
1
for review in updated_reviews.values()
if review.status == ReviewStatus.APPROVED
)
rejected_count = sum(
1
for review in updated_reviews.values()
if review.status == ReviewStatus.REJECTED
)
# Resume graph execution only for real graph executions (not CoPilot)
# CoPilot sessions are resumed by the LLM retrying run_block with review_id
if not is_copilot and updated_reviews:
still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id)
if not still_has_pending:
first_review = next(iter(updated_reviews.values()))
try:
user = await get_user_by_id(user_id)
settings = await get_graph_settings(
user_id=user_id, graph_id=first_review.graph_id
)
user_timezone = (
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
)
workspace = await get_or_create_workspace(user_id)
execution_context = ExecutionContext(
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
user_timezone=user_timezone,
workspace_id=workspace.id,
)
await add_graph_execution(
graph_id=first_review.graph_id,
user_id=user_id,
graph_exec_id=graph_exec_id,
execution_context=execution_context,
)
logger.info(f"Resumed execution {graph_exec_id}")
except Exception as e:
logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}")
# Build error message if auto-approvals failed
error_message = None
if auto_approval_failed_count > 0:
error_message = (
f"{auto_approval_failed_count} auto-approval setting(s) could not be saved. "
f"You may need to manually approve these reviews in future executions."
)
return ReviewResponse(
approved_count=approved_count,
rejected_count=rejected_count,
failed_count=auto_approval_failed_count,
error=error_message,
)

View File

@@ -489,16 +489,11 @@ async def _execute_webhook_node_trigger(
return
logger.debug(f"Executing graph #{node.graph_id} node #{node.id}")
try:
from backend.api.features.orgs.db import get_user_default_team
org_id, ws_id = await get_user_default_team(webhook.user_id)
await add_graph_execution(
user_id=webhook.user_id,
graph_id=node.graph_id,
graph_version=node.graph_version,
nodes_input_masks={node.id: {"payload": payload}},
organization_id=org_id,
team_id=ws_id,
)
except GraphNotInLibraryError as e:
logger.warning(
@@ -555,9 +550,6 @@ async def _execute_webhook_preset_trigger(
logger.debug(f"Executing preset #{preset.id} for webhook #{webhook.id}")
try:
from backend.api.features.orgs.db import get_user_default_team
org_id, ws_id = await get_user_default_team(webhook.user_id)
await add_graph_execution(
user_id=webhook.user_id,
graph_id=preset.graph_id,
@@ -565,8 +557,6 @@ async def _execute_webhook_preset_trigger(
graph_version=preset.graph_version,
graph_credentials_inputs=preset.credentials,
nodes_input_masks={trigger_node.id: {**preset.inputs, "payload": payload}},
organization_id=org_id,
team_id=ws_id,
)
except GraphNotInLibraryError as e:
logger.warning(

View File

@@ -24,7 +24,6 @@ async def test_get_library_agents(mocker):
userId="test-user",
isActive=True,
createdAt=datetime.now(),
visibility=prisma.enums.ResourceVisibility.PRIVATE,
)
]
@@ -50,7 +49,6 @@ async def test_get_library_agents(mocker):
userId="other-user",
isActive=True,
createdAt=datetime.now(),
visibility=prisma.enums.ResourceVisibility.PRIVATE,
),
)
]
@@ -115,7 +113,6 @@ async def test_add_agent_to_library(mocker):
userId="creator",
isActive=True,
createdAt=datetime.now(),
visibility=prisma.enums.ResourceVisibility.PRIVATE,
),
)

View File

@@ -1,6 +1,5 @@
import datetime
import prisma.enums
import prisma.models
import pytest
@@ -21,7 +20,6 @@ async def test_agent_preset_from_db(test_user_id: str):
isActive=True,
userId=test_user_id,
isDeleted=False,
visibility=prisma.enums.ResourceVisibility.PRIVATE,
InputPresets=[
prisma.models.AgentNodeExecutionInputOutput.model_validate(
{

View File

@@ -371,9 +371,6 @@ async def delete_preset(
async def execute_preset(
preset_id: str,
user_id: str = Security(autogpt_auth_lib.get_user_id),
ctx: autogpt_auth_lib.RequestContext = Security(
autogpt_auth_lib.get_request_context
),
inputs: dict[str, Any] = Body(..., embed=True, default_factory=dict),
credential_inputs: dict[str, CredentialsMetaInput] = Body(
..., embed=True, default_factory=dict
@@ -412,6 +409,4 @@ async def execute_preset(
preset_id=preset_id,
inputs=merged_node_input,
graph_credentials_inputs=merged_credential_inputs,
organization_id=ctx.org_id,
team_id=ctx.team_id,
)

View File

@@ -1,19 +0,0 @@
"""Override session-scoped fixtures for org tests.
Org tests mock at the Prisma boundary and don't need the full test server
or its graph cleanup hook.
"""
import pytest
@pytest.fixture(scope="session")
def server():
"""No-op — org tests don't need the full backend server."""
yield None
@pytest.fixture(scope="session", autouse=True)
def graph_cleanup():
"""No-op — org tests don't create real graphs."""
yield

View File

@@ -1,573 +0,0 @@
"""Database operations for organization management."""
import logging
from datetime import datetime, timezone
import prisma.errors
from backend.data.db import prisma
from backend.data.org_migration import _resolve_unique_slug, _sanitize_slug
from backend.util.exceptions import NotFoundError
from .model import OrgAliasResponse, OrgMemberResponse, OrgResponse, UpdateOrgData
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
async def get_user_default_team(
user_id: str,
) -> tuple[str | None, str | None]:
"""Get the user's personal org ID and its default workspace ID.
Returns (organization_id, team_id). Either may be None if
the user has no org (e.g., migration hasn't run yet).
"""
member = await prisma.orgmember.find_first(
where={
"userId": user_id,
"isOwner": True,
"Org": {"isPersonal": True, "deletedAt": None},
},
)
if member is None:
logger.warning(
f"User {user_id} has no personal org — account may be in inconsistent state"
)
return None, None
org_id = member.orgId
workspace = await prisma.team.find_first(
where={"orgId": org_id, "isDefault": True}
)
ws_id = workspace.id if workspace else None
return org_id, ws_id
async def _create_personal_org_for_user(
user_id: str,
slug_base: str,
display_name: str,
) -> OrgResponse:
"""Create a new personal org with all required records.
Used by both initial org creation (migration) and conversion (spawning
a new personal org when the old one becomes a team org).
"""
slug = await _resolve_unique_slug(slug_base)
org = await prisma.organization.create(
data={
"name": display_name,
"slug": slug,
"isPersonal": True,
"bootstrapUserId": user_id,
"settings": "{}",
}
)
await prisma.orgmember.create(
data={
"orgId": org.id,
"userId": user_id,
"isOwner": True,
"isAdmin": True,
"status": "ACTIVE",
}
)
workspace = await prisma.team.create(
data={
"name": "Default",
"orgId": org.id,
"isDefault": True,
"joinPolicy": "OPEN",
"createdByUserId": user_id,
}
)
await prisma.teammember.create(
data={
"teamId": workspace.id,
"userId": user_id,
"isAdmin": True,
"status": "ACTIVE",
}
)
await prisma.organizationprofile.create(
data={
"organizationId": org.id,
"username": slug,
"displayName": display_name,
}
)
await prisma.organizationseatassignment.create(
data={
"organizationId": org.id,
"userId": user_id,
"seatType": "FREE",
"status": "ACTIVE",
"assignedByUserId": user_id,
}
)
# Create zero-balance row so credit operations don't need upsert
await prisma.orgbalance.create(data={"orgId": org.id, "balance": 0})
return OrgResponse.from_db(org, member_count=1)
# ---------------------------------------------------------------------------
# Org CRUD
# ---------------------------------------------------------------------------
async def create_org(
name: str,
slug: str,
user_id: str,
description: str | None = None,
) -> OrgResponse:
"""Create a team organization and make the user the owner.
Raises:
ValueError: If the slug is already taken by another org or alias.
"""
existing_org = await prisma.organization.find_unique(where={"slug": slug})
if existing_org:
raise ValueError(f"Slug '{slug}' is already in use")
existing_alias = await prisma.organizationalias.find_unique(
where={"aliasSlug": slug}
)
if existing_alias:
raise ValueError(f"Slug '{slug}' is already in use as an alias")
org = await prisma.organization.create(
data={
"name": name,
"slug": slug,
"description": description,
"isPersonal": False,
"bootstrapUserId": user_id,
"settings": "{}",
}
)
await prisma.orgmember.create(
data={
"orgId": org.id,
"userId": user_id,
"isOwner": True,
"isAdmin": True,
"status": "ACTIVE",
}
)
workspace = await prisma.team.create(
data={
"name": "Default",
"orgId": org.id,
"isDefault": True,
"joinPolicy": "OPEN",
"createdByUserId": user_id,
}
)
await prisma.teammember.create(
data={
"teamId": workspace.id,
"userId": user_id,
"isAdmin": True,
"status": "ACTIVE",
}
)
await prisma.organizationprofile.create(
data={
"organizationId": org.id,
"username": slug,
"displayName": name,
}
)
await prisma.organizationseatassignment.create(
data={
"organizationId": org.id,
"userId": user_id,
"seatType": "FREE",
"status": "ACTIVE",
"assignedByUserId": user_id,
}
)
# Create zero-balance row so credit operations don't need upsert
await prisma.orgbalance.create(data={"orgId": org.id, "balance": 0})
return OrgResponse.from_db(org, member_count=1)
async def list_user_orgs(user_id: str) -> list[OrgResponse]:
"""List all non-deleted organizations the user belongs to."""
memberships = await prisma.orgmember.find_many(
where={
"userId": user_id,
"status": "ACTIVE",
"Org": {"deletedAt": None},
},
include={"Org": True},
)
results = []
for m in memberships:
org = m.Org
if org is None:
continue
results.append(OrgResponse.from_db(org))
return results
async def get_org(org_id: str) -> OrgResponse:
"""Get organization details."""
org = await prisma.organization.find_unique(where={"id": org_id})
if org is None or org.deletedAt is not None:
raise NotFoundError(f"Organization {org_id} not found")
return OrgResponse.from_db(org)
async def update_org(org_id: str, data: UpdateOrgData) -> OrgResponse:
"""Update organization fields. Creates a RENAME alias if slug changes.
Only accepts the structured UpdateOrgData model — no arbitrary dict keys.
"""
update_dict: dict = {}
if data.name is not None:
update_dict["name"] = data.name
if data.description is not None:
update_dict["description"] = data.description
if data.avatar_url is not None:
update_dict["avatarUrl"] = data.avatar_url
if data.slug is not None:
existing = await prisma.organization.find_unique(where={"slug": data.slug})
if existing and existing.id != org_id:
raise ValueError(f"Slug '{data.slug}' is already in use")
existing_alias = await prisma.organizationalias.find_unique(
where={"aliasSlug": data.slug}
)
if existing_alias:
raise ValueError(f"Slug '{data.slug}' is already in use as an alias")
old_org = await prisma.organization.find_unique(where={"id": org_id})
if old_org and old_org.slug != data.slug:
await prisma.organizationalias.create(
data={
"organizationId": org_id,
"aliasSlug": old_org.slug,
"aliasType": "RENAME",
}
)
update_dict["slug"] = data.slug
if not update_dict:
return await get_org(org_id)
await prisma.organization.update(where={"id": org_id}, data=update_dict)
# Sync OrganizationProfile when name or slug changes
profile_update: dict = {}
if data.name is not None:
profile_update["displayName"] = data.name
if data.slug is not None:
profile_update["username"] = data.slug
if profile_update:
await prisma.organizationprofile.update(
where={"organizationId": org_id},
data=profile_update,
)
return await get_org(org_id)
async def delete_org(org_id: str) -> None:
"""Soft-delete an organization. Cannot delete personal orgs.
Sets deletedAt instead of hard-deleting to preserve financial records.
"""
org = await prisma.organization.find_unique(where={"id": org_id})
if org is None:
raise NotFoundError(f"Organization {org_id} not found")
if org.isPersonal:
raise ValueError("Cannot delete a personal organization. Convert it first.")
if org.deletedAt is not None:
raise ValueError("Organization is already deleted")
await prisma.organization.update(
where={"id": org_id},
data={"deletedAt": datetime.now(timezone.utc)},
)
async def convert_personal_org(org_id: str, user_id: str) -> OrgResponse:
"""Convert a personal org to a team org.
Creates a new personal org for the user so they always have one.
Existing resources (agents, credits, store listings) stay in the
team org — that's the point of converting.
If new personal org creation fails, the conversion is rolled back.
"""
org = await prisma.organization.find_unique(where={"id": org_id})
if org is None:
raise NotFoundError(f"Organization {org_id} not found")
if not org.isPersonal:
raise ValueError("Organization is already a team org")
# Step 1: Flip isPersonal on the old org
await prisma.organization.update(
where={"id": org_id},
data={"isPersonal": False},
)
# Step 2: Create a new personal org for the user
try:
slug_base = f"{_sanitize_slug(org.slug)}-personal-1"
# Fetch user name for display
user = await prisma.user.find_unique(where={"id": user_id})
display_name = user.name if user and user.name else org.name
await _create_personal_org_for_user(
user_id=user_id,
slug_base=slug_base,
display_name=display_name,
)
except Exception:
# Roll back: restore isPersonal on the old org
logger.exception(
f"Failed to create new personal org for user {user_id} during "
f"conversion of org {org_id} — rolling back"
)
await prisma.organization.update(
where={"id": org_id},
data={"isPersonal": True},
)
raise
return await get_org(org_id)
# ---------------------------------------------------------------------------
# Members
# ---------------------------------------------------------------------------
async def list_org_members(org_id: str) -> list[OrgMemberResponse]:
"""List all active members of an organization."""
members = await prisma.orgmember.find_many(
where={"orgId": org_id, "status": "ACTIVE"},
include={"User": True},
)
return [OrgMemberResponse.from_db(m) for m in members]
async def add_org_member(
org_id: str,
user_id: str,
is_admin: bool = False,
is_billing_manager: bool = False,
invited_by: str | None = None,
) -> OrgMemberResponse:
"""Add a member to an organization and its default workspace."""
member = await prisma.orgmember.create(
data={
"orgId": org_id,
"userId": user_id,
"isAdmin": is_admin,
"isBillingManager": is_billing_manager,
"status": "ACTIVE",
"invitedByUserId": invited_by,
},
include={"User": True},
)
default_ws = await prisma.team.find_first(
where={"orgId": org_id, "isDefault": True}
)
if default_ws:
await prisma.teammember.create(
data={
"teamId": default_ws.id,
"userId": user_id,
"status": "ACTIVE",
}
)
return OrgMemberResponse.from_db(member)
async def update_org_member(
org_id: str, user_id: str, is_admin: bool | None, is_billing_manager: bool | None
) -> OrgMemberResponse:
"""Update a member's role flags."""
member = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
if member is None:
raise NotFoundError(f"Member {user_id} not found in org {org_id}")
if member.isOwner:
raise ValueError(
"Cannot change the owner's role flags directly. Use transfer-ownership."
)
update_data: dict = {}
if is_admin is not None:
update_data["isAdmin"] = is_admin
if is_billing_manager is not None:
update_data["isBillingManager"] = is_billing_manager
if update_data:
await prisma.orgmember.update(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}},
data=update_data,
)
members = await list_org_members(org_id)
match = next((m for m in members if m.user_id == user_id), None)
if match is None:
raise NotFoundError(f"Member {user_id} not found in org {org_id} after update")
return match
async def remove_org_member(org_id: str, user_id: str, requesting_user_id: str) -> None:
"""Remove a member from an organization and all its workspaces.
Guards:
- Cannot remove the org owner (transfer ownership first)
- Cannot remove yourself (use leave flow instead)
- Cannot remove a user who has active schedules (transfer/cancel first)
- Cannot remove a user who would become org-less (no other org memberships)
"""
member = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
if member is None:
raise NotFoundError(f"Member {user_id} not found in org {org_id}")
if member.isOwner:
raise ValueError("Cannot remove the org owner. Transfer ownership first.")
if user_id == requesting_user_id:
raise ValueError(
"Cannot remove yourself from an organization. "
"Ask another admin to remove you, or transfer ownership first."
)
# Check if user would become org-less
other_memberships = await prisma.orgmember.count(
where={
"userId": user_id,
"status": "ACTIVE",
"orgId": {"not": org_id},
"Org": {"deletedAt": None},
}
)
if other_memberships == 0:
raise ValueError(
"Cannot remove this member — they have no other organization memberships "
"and would be locked out. They must join or create another org first."
)
# Check for active schedules
# TODO: Check APScheduler for active schedules owned by this user in this org
# For now, this is a placeholder for the schedule transfer requirement
# Remove from all workspaces in this org
workspaces = await prisma.team.find_many(where={"orgId": org_id})
for ws in workspaces:
await prisma.teammember.delete_many(
where={"teamId": ws.id, "userId": user_id}
)
# Remove org membership
await prisma.orgmember.delete(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
async def transfer_ownership(
org_id: str, current_owner_id: str, new_owner_id: str
) -> None:
"""Transfer org ownership atomically. Both updates happen in one statement."""
if current_owner_id == new_owner_id:
raise ValueError("Cannot transfer ownership to the same user")
current = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": current_owner_id}}
)
if current is None or not current.isOwner:
raise ValueError("Current user is not the org owner")
new = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": new_owner_id}}
)
if new is None:
raise NotFoundError(f"User {new_owner_id} is not a member of org {org_id}")
await prisma.execute_raw(
"""
UPDATE "OrgMember"
SET "isOwner" = CASE
WHEN "userId" = $1 THEN false
WHEN "userId" = $2 THEN true
ELSE "isOwner"
END,
"isAdmin" = CASE
WHEN "userId" = $2 THEN true
ELSE "isAdmin"
END,
"updatedAt" = NOW()
WHERE "orgId" = $3 AND "userId" IN ($1, $2)
""",
current_owner_id,
new_owner_id,
org_id,
)
# ---------------------------------------------------------------------------
# Aliases
# ---------------------------------------------------------------------------
async def list_org_aliases(org_id: str) -> list[OrgAliasResponse]:
"""List all aliases for an organization."""
aliases = await prisma.organizationalias.find_many(
where={"organizationId": org_id, "removedAt": None}
)
return [OrgAliasResponse.from_db(a) for a in aliases]
async def create_org_alias(
org_id: str, alias_slug: str, user_id: str
) -> OrgAliasResponse:
"""Create a new alias for an organization."""
existing_org = await prisma.organization.find_unique(where={"slug": alias_slug})
if existing_org:
raise ValueError(f"Slug '{alias_slug}' is already used by an organization")
existing_alias = await prisma.organizationalias.find_unique(
where={"aliasSlug": alias_slug}
)
if existing_alias:
raise ValueError(f"Slug '{alias_slug}' is already used as an alias")
alias = await prisma.organizationalias.create(
data={
"organizationId": org_id,
"aliasSlug": alias_slug,
"aliasType": "MANUAL",
"createdByUserId": user_id,
}
)
return OrgAliasResponse.from_db(alias)

View File

@@ -1,247 +0,0 @@
"""Invitation API routes for organization membership."""
from datetime import datetime, timedelta, timezone
from typing import Annotated
from autogpt_libs.auth import get_user_id, requires_org_permission, requires_user
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import OrgAction
from fastapi import APIRouter, HTTPException, Security
from prisma.errors import UniqueViolationError
from backend.data.db import prisma
from backend.util.exceptions import NotFoundError
from . import db as org_db
from .model import CreateInvitationRequest, InvitationResponse
router = APIRouter()
INVITATION_TTL_DAYS = 7
def _verify_org_path(ctx: RequestContext, org_id: str) -> None:
"""Ensure the authenticated user's active org matches the path parameter."""
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
# --- Org-scoped invitation endpoints (under /api/orgs/{org_id}/invitations) ---
org_router = APIRouter()
@org_router.post(
"",
summary="Create invitation",
tags=["orgs", "invitations"],
)
async def create_invitation(
org_id: str,
request: CreateInvitationRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
],
) -> InvitationResponse:
_verify_org_path(ctx, org_id)
expires_at = datetime.now(timezone.utc) + timedelta(days=INVITATION_TTL_DAYS)
invitation = await prisma.orginvitation.create(
data={
"orgId": org_id,
"email": request.email,
"isAdmin": request.is_admin,
"isBillingManager": request.is_billing_manager,
"expiresAt": expires_at,
"invitedByUserId": ctx.user_id,
"teamIds": request.team_ids,
}
)
# TODO: Send email via Postmark with invitation link
# link = f"{frontend_base_url}/org/invite/{invitation.token}"
return InvitationResponse.from_db(invitation)
@org_router.get(
"",
summary="List pending invitations",
tags=["orgs", "invitations"],
)
async def list_invitations(
org_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
],
) -> list[InvitationResponse]:
_verify_org_path(ctx, org_id)
invitations = await prisma.orginvitation.find_many(
where={
"orgId": org_id,
"acceptedAt": None,
"revokedAt": None,
"expiresAt": {"gt": datetime.now(timezone.utc)},
},
order={"createdAt": "desc"},
)
return [InvitationResponse.from_db(inv) for inv in invitations]
@org_router.delete(
"/{invitation_id}",
summary="Revoke invitation",
tags=["orgs", "invitations"],
status_code=204,
)
async def revoke_invitation(
org_id: str,
invitation_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
],
) -> None:
_verify_org_path(ctx, org_id)
invitation = await prisma.orginvitation.find_unique(where={"id": invitation_id})
if invitation is None or invitation.orgId != org_id:
raise NotFoundError(f"Invitation {invitation_id} not found")
await prisma.orginvitation.update(
where={"id": invitation_id},
data={"revokedAt": datetime.now(timezone.utc)},
)
# --- Token-based endpoints (under /api/invitations) ---
@router.post(
"/{token}/accept",
summary="Accept invitation",
tags=["invitations"],
dependencies=[Security(requires_user)],
)
async def accept_invitation(
token: str,
user_id: Annotated[str, Security(get_user_id)],
) -> dict:
invitation = await prisma.orginvitation.find_unique(where={"token": token})
if invitation is None:
raise NotFoundError("Invitation not found")
if invitation.acceptedAt is not None:
raise HTTPException(400, detail="Invitation already accepted")
if invitation.revokedAt is not None:
raise HTTPException(400, detail="Invitation has been revoked")
if invitation.expiresAt < datetime.now(timezone.utc):
raise HTTPException(400, detail="Invitation has expired")
# Verify the accepting user's email matches the invitation
accepting_user = await prisma.user.find_unique(where={"id": user_id})
if accepting_user is None:
raise HTTPException(401, detail="User not found")
if accepting_user.email.lower() != invitation.email.lower():
raise HTTPException(
403,
detail="This invitation was sent to a different email address",
)
# Add user to org (idempotent — handles race condition from concurrent accepts)
try:
await org_db.add_org_member(
org_id=invitation.orgId,
user_id=user_id,
is_admin=invitation.isAdmin,
is_billing_manager=invitation.isBillingManager,
invited_by=invitation.invitedByUserId,
)
except UniqueViolationError:
# User is already a member — treat as success (idempotent)
pass
# Add to specified workspaces
for ws_id in invitation.teamIds:
try:
from . import team_db as team_db
await team_db.add_team_member(
ws_id=ws_id,
user_id=user_id,
org_id=invitation.orgId,
invited_by=invitation.invitedByUserId,
)
except Exception:
# Non-fatal -- workspace may have been deleted
pass
# Mark invitation as accepted
await prisma.orginvitation.update(
where={"id": invitation.id},
data={"acceptedAt": datetime.now(timezone.utc), "targetUserId": user_id},
)
return {"orgId": invitation.orgId, "message": "Invitation accepted"}
@router.post(
"/{token}/decline",
summary="Decline invitation",
tags=["invitations"],
dependencies=[Security(requires_user)],
status_code=204,
)
async def decline_invitation(
token: str,
user_id: Annotated[str, Security(get_user_id)],
) -> None:
invitation = await prisma.orginvitation.find_unique(where={"token": token})
if invitation is None:
raise NotFoundError("Invitation not found")
# State checks — same as accept_invitation
if invitation.acceptedAt is not None:
raise HTTPException(400, detail="Invitation already accepted")
if invitation.revokedAt is not None:
raise HTTPException(400, detail="Invitation already revoked")
if invitation.expiresAt < datetime.now(timezone.utc):
raise HTTPException(400, detail="Invitation has expired")
# Verify the declining user's email matches the invitation
declining_user = await prisma.user.find_unique(where={"id": user_id})
if declining_user is None:
raise HTTPException(401, detail="User not found")
if declining_user.email.lower() != invitation.email.lower():
raise HTTPException(403, detail="This invitation was sent to a different email address")
await prisma.orginvitation.update(
where={"id": invitation.id},
data={"revokedAt": datetime.now(timezone.utc)},
)
@router.get(
"/pending",
summary="List pending invitations for current user",
tags=["invitations"],
dependencies=[Security(requires_user)],
)
async def list_pending_for_user(
user_id: Annotated[str, Security(get_user_id)],
) -> list[InvitationResponse]:
# Get user's email
user = await prisma.user.find_unique(where={"id": user_id})
if user is None:
return []
invitations = await prisma.orginvitation.find_many(
where={
"email": user.email,
"acceptedAt": None,
"revokedAt": None,
"expiresAt": {"gt": datetime.now(timezone.utc)},
},
order={"createdAt": "desc"},
)
return [InvitationResponse.from_db(inv) for inv in invitations]

View File

@@ -1,148 +0,0 @@
"""Pydantic request/response models for organization management."""
from datetime import datetime
from pydantic import BaseModel, Field
class CreateOrgRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(
..., min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$"
)
description: str | None = None
class UpdateOrgRequest(BaseModel):
name: str | None = None
slug: str | None = Field(None, pattern=r"^[a-z0-9][a-z0-9-]*$")
description: str | None = None
avatar_url: str | None = None
class UpdateOrgData(BaseModel):
"""Structured data object for update_org DB function.
Only these fields can be updated — no arbitrary dict keys.
"""
name: str | None = None
slug: str | None = None
description: str | None = None
avatar_url: str | None = None
class OrgResponse(BaseModel):
id: str
name: str
slug: str
avatar_url: str | None
description: str | None
is_personal: bool
member_count: int
created_at: datetime
@staticmethod
def from_db(org, member_count: int = 0) -> "OrgResponse":
return OrgResponse(
id=org.id,
name=org.name,
slug=org.slug,
avatar_url=org.avatarUrl,
description=org.description,
is_personal=org.isPersonal,
member_count=member_count,
created_at=org.createdAt,
)
class OrgMemberResponse(BaseModel):
id: str
user_id: str
email: str
name: str | None
is_owner: bool
is_admin: bool
is_billing_manager: bool
joined_at: datetime
@staticmethod
def from_db(member) -> "OrgMemberResponse":
return OrgMemberResponse(
id=member.id,
user_id=member.userId,
email=member.User.email if member.User else "",
name=member.User.name if member.User else None,
is_owner=member.isOwner,
is_admin=member.isAdmin,
is_billing_manager=member.isBillingManager,
joined_at=member.joinedAt,
)
class AddMemberRequest(BaseModel):
user_id: str
is_admin: bool = False
is_billing_manager: bool = False
class UpdateMemberRequest(BaseModel):
is_admin: bool | None = None
is_billing_manager: bool | None = None
class TransferOwnershipRequest(BaseModel):
new_owner_id: str
class OrgAliasResponse(BaseModel):
id: str
alias_slug: str
alias_type: str
created_at: datetime
@staticmethod
def from_db(alias) -> "OrgAliasResponse":
return OrgAliasResponse(
id=alias.id,
alias_slug=alias.aliasSlug,
alias_type=alias.aliasType,
created_at=alias.createdAt,
)
class CreateAliasRequest(BaseModel):
alias_slug: str = Field(
..., min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$"
)
class CreateInvitationRequest(BaseModel):
email: str
is_admin: bool = False
is_billing_manager: bool = False
team_ids: list[str] = Field(default_factory=list)
class InvitationResponse(BaseModel):
id: str
email: str
is_admin: bool
is_billing_manager: bool
token: str
expires_at: datetime
created_at: datetime
team_ids: list[str]
@staticmethod
def from_db(inv) -> "InvitationResponse":
return InvitationResponse(
id=inv.id,
email=inv.email,
is_admin=inv.isAdmin,
is_billing_manager=inv.isBillingManager,
token=inv.token,
expires_at=inv.expiresAt,
created_at=inv.createdAt,
team_ids=inv.teamIds,
)

View File

@@ -1,273 +0,0 @@
"""Organization management API routes."""
from typing import Annotated
from autogpt_libs.auth import (
get_request_context,
get_user_id,
requires_org_permission,
requires_user,
)
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import OrgAction
from fastapi import APIRouter, HTTPException, Security
from . import db as org_db
from .model import (
AddMemberRequest,
CreateAliasRequest,
CreateOrgRequest,
OrgAliasResponse,
OrgMemberResponse,
OrgResponse,
TransferOwnershipRequest,
UpdateMemberRequest,
UpdateOrgData,
UpdateOrgRequest,
)
router = APIRouter()
def _verify_org_path(ctx: RequestContext, org_id: str) -> None:
"""Ensure the authenticated user's active org matches the path parameter.
Prevents authorization bypass where a user sends X-Org-Id for org A
but targets org B in the URL path.
"""
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
@router.post(
"",
summary="Create organization",
tags=["orgs"],
dependencies=[Security(requires_user)],
)
async def create_org(
request: CreateOrgRequest,
user_id: Annotated[str, Security(get_user_id)],
) -> OrgResponse:
return await org_db.create_org(
name=request.name,
slug=request.slug,
user_id=user_id,
description=request.description,
)
@router.get(
"",
summary="List user organizations",
tags=["orgs"],
dependencies=[Security(requires_user)],
)
async def list_orgs(
user_id: Annotated[str, Security(get_user_id)],
) -> list[OrgResponse]:
return await org_db.list_user_orgs(user_id)
@router.get(
"/{org_id}",
summary="Get organization details",
tags=["orgs"],
)
async def get_org(
org_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> OrgResponse:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await org_db.get_org(org_id)
@router.patch(
"/{org_id}",
summary="Update organization",
tags=["orgs"],
)
async def update_org(
org_id: str,
request: UpdateOrgRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.RENAME_ORG)),
],
) -> OrgResponse:
_verify_org_path(ctx, org_id)
return await org_db.update_org(
org_id,
UpdateOrgData(
name=request.name,
slug=request.slug,
description=request.description,
avatar_url=request.avatar_url,
),
)
@router.delete(
"/{org_id}",
summary="Delete organization",
tags=["orgs"],
status_code=204,
)
async def delete_org(
org_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.DELETE_ORG)),
],
) -> None:
_verify_org_path(ctx, org_id)
await org_db.delete_org(org_id)
@router.post(
"/{org_id}/convert",
summary="Convert personal org to team org",
tags=["orgs"],
)
async def convert_org(
org_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.DELETE_ORG)),
],
) -> OrgResponse:
_verify_org_path(ctx, org_id)
return await org_db.convert_personal_org(org_id, ctx.user_id)
# --- Members ---
@router.get(
"/{org_id}/members",
summary="List organization members",
tags=["orgs"],
)
async def list_members(
org_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[OrgMemberResponse]:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await org_db.list_org_members(org_id)
@router.post(
"/{org_id}/members",
summary="Add member to organization",
tags=["orgs"],
)
async def add_member(
org_id: str,
request: AddMemberRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
],
) -> OrgMemberResponse:
_verify_org_path(ctx, org_id)
return await org_db.add_org_member(
org_id=org_id,
user_id=request.user_id,
is_admin=request.is_admin,
is_billing_manager=request.is_billing_manager,
invited_by=ctx.user_id,
)
@router.patch(
"/{org_id}/members/{uid}",
summary="Update member role",
tags=["orgs"],
)
async def update_member(
org_id: str,
uid: str,
request: UpdateMemberRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
],
) -> OrgMemberResponse:
_verify_org_path(ctx, org_id)
return await org_db.update_org_member(
org_id=org_id,
user_id=uid,
is_admin=request.is_admin,
is_billing_manager=request.is_billing_manager,
)
@router.delete(
"/{org_id}/members/{uid}",
summary="Remove member from organization",
tags=["orgs"],
status_code=204,
)
async def remove_member(
org_id: str,
uid: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_MEMBERS)),
],
) -> None:
_verify_org_path(ctx, org_id)
await org_db.remove_org_member(org_id, uid, requesting_user_id=ctx.user_id)
@router.post(
"/{org_id}/transfer-ownership",
summary="Transfer organization ownership",
tags=["orgs"],
)
async def transfer_ownership(
org_id: str,
request: TransferOwnershipRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.DELETE_ORG)),
],
) -> None:
_verify_org_path(ctx, org_id)
await org_db.transfer_ownership(org_id, ctx.user_id, request.new_owner_id)
# --- Aliases ---
@router.get(
"/{org_id}/aliases",
summary="List organization aliases",
tags=["orgs"],
)
async def list_aliases(
org_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[OrgAliasResponse]:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await org_db.list_org_aliases(org_id)
@router.post(
"/{org_id}/aliases",
summary="Create organization alias",
tags=["orgs"],
)
async def create_alias(
org_id: str,
request: CreateAliasRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.RENAME_ORG)),
],
) -> OrgAliasResponse:
_verify_org_path(ctx, org_id)
return await org_db.create_org_alias(org_id, request.alias_slug, ctx.user_id)

View File

@@ -1,234 +0,0 @@
"""Database operations for workspace management."""
import logging
from backend.data.db import prisma
from backend.util.exceptions import NotFoundError
from .team_model import TeamMemberResponse, TeamResponse
logger = logging.getLogger(__name__)
async def create_team(
org_id: str,
name: str,
user_id: str,
description: str | None = None,
join_policy: str = "OPEN",
) -> TeamResponse:
"""Create a workspace and make the creator an admin."""
ws = await prisma.team.create(
data={
"name": name,
"orgId": org_id,
"description": description,
"joinPolicy": join_policy,
"createdByUserId": user_id,
}
)
# Creator becomes admin
await prisma.teammember.create(
data={
"teamId": ws.id,
"userId": user_id,
"isAdmin": True,
"status": "ACTIVE",
}
)
return TeamResponse.from_db(ws, member_count=1)
async def list_teams(org_id: str, user_id: str) -> list[TeamResponse]:
"""List workspaces: all OPEN workspaces + PRIVATE ones the user belongs to."""
workspaces = await prisma.team.find_many(
where={
"orgId": org_id,
"archivedAt": None,
"OR": [
{"joinPolicy": "OPEN"},
{"Members": {"some": {"userId": user_id, "status": "ACTIVE"}}},
],
},
order={"createdAt": "asc"},
)
return [TeamResponse.from_db(ws) for ws in workspaces]
async def get_team(
ws_id: str, expected_org_id: str | None = None
) -> TeamResponse:
"""Get workspace details. Validates org ownership if expected_org_id is given."""
ws = await prisma.team.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if expected_org_id and ws.orgId != expected_org_id:
raise NotFoundError(f"Workspace {ws_id} not found in org {expected_org_id}")
return TeamResponse.from_db(ws)
async def update_team(ws_id: str, data: dict) -> TeamResponse:
"""Update workspace fields. Guards the default workspace join policy."""
update_data = {k: v for k, v in data.items() if v is not None}
if not update_data:
return await get_team(ws_id)
# Guard: default workspace joinPolicy cannot be changed
if "joinPolicy" in update_data:
ws = await prisma.team.find_unique(where={"id": ws_id})
if ws and ws.isDefault:
raise ValueError("Cannot change the default workspace's join policy")
await prisma.team.update(where={"id": ws_id}, data=update_data)
return await get_team(ws_id)
async def delete_team(ws_id: str) -> None:
"""Delete a workspace. Cannot delete the default workspace."""
ws = await prisma.team.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if ws.isDefault:
raise ValueError("Cannot delete the default workspace")
await prisma.team.delete(where={"id": ws_id})
async def join_team(ws_id: str, user_id: str, org_id: str) -> TeamResponse:
"""Self-join an OPEN workspace. User must be an org member."""
ws = await prisma.team.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if ws.orgId != org_id:
raise ValueError("Workspace does not belong to this organization")
if ws.joinPolicy != "OPEN":
raise ValueError("Cannot self-join a PRIVATE workspace. Request an invite.")
# Verify user is actually an org member
org_member = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
if org_member is None:
raise ValueError(f"User {user_id} is not a member of the organization")
# Check not already a member
existing = await prisma.teammember.find_unique(
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
)
if existing:
return TeamResponse.from_db(ws)
await prisma.teammember.create(
data={
"teamId": ws_id,
"userId": user_id,
"status": "ACTIVE",
}
)
return TeamResponse.from_db(ws)
async def leave_team(ws_id: str, user_id: str) -> None:
"""Leave a workspace. Cannot leave the default workspace."""
ws = await prisma.team.find_unique(where={"id": ws_id})
if ws is None:
raise NotFoundError(f"Workspace {ws_id} not found")
if ws.isDefault:
raise ValueError("Cannot leave the default workspace")
await prisma.teammember.delete_many(
where={"teamId": ws_id, "userId": user_id}
)
async def list_team_members(ws_id: str) -> list[TeamMemberResponse]:
"""List all active members of a workspace."""
members = await prisma.teammember.find_many(
where={"teamId": ws_id, "status": "ACTIVE"},
include={"User": True},
)
return [TeamMemberResponse.from_db(m) for m in members]
async def add_team_member(
ws_id: str,
user_id: str,
org_id: str,
is_admin: bool = False,
is_billing_manager: bool = False,
invited_by: str | None = None,
) -> TeamMemberResponse:
"""Add a member to a workspace. Must be an org member, workspace must belong to org."""
# Verify workspace belongs to the org
ws = await prisma.team.find_unique(where={"id": ws_id})
if ws is None or ws.orgId != org_id:
raise ValueError(f"Workspace {ws_id} does not belong to org {org_id}")
# Verify user is in the org
org_member = await prisma.orgmember.find_unique(
where={"orgId_userId": {"orgId": org_id, "userId": user_id}}
)
if org_member is None:
raise ValueError(f"User {user_id} is not a member of the organization")
member = await prisma.teammember.create(
data={
"teamId": ws_id,
"userId": user_id,
"isAdmin": is_admin,
"isBillingManager": is_billing_manager,
"status": "ACTIVE",
"invitedByUserId": invited_by,
},
include={"User": True},
)
return TeamMemberResponse.from_db(member)
async def update_team_member(
ws_id: str,
user_id: str,
is_admin: bool | None,
is_billing_manager: bool | None,
) -> TeamMemberResponse:
"""Update a workspace member's role flags."""
update_data: dict = {}
if is_admin is not None:
update_data["isAdmin"] = is_admin
if is_billing_manager is not None:
update_data["isBillingManager"] = is_billing_manager
if update_data:
await prisma.teammember.update(
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}},
data=update_data,
)
members = await list_team_members(ws_id)
return next(m for m in members if m.user_id == user_id)
async def remove_team_member(ws_id: str, user_id: str) -> None:
"""Remove a member from a workspace.
Guards against removing the last admin — workspace would become unmanageable.
"""
# Check if this would remove the last admin
member = await prisma.teammember.find_unique(
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
)
if member and member.isAdmin:
admin_count = await prisma.teammember.count(
where={"teamId": ws_id, "isAdmin": True, "status": "ACTIVE"}
)
if admin_count <= 1:
raise ValueError(
"Cannot remove the last workspace admin. "
"Promote another member to admin first."
)
await prisma.teammember.delete(
where={"teamId_userId": {"teamId": ws_id, "userId": user_id}}
)

View File

@@ -1,76 +0,0 @@
"""Pydantic request/response models for workspace management."""
from datetime import datetime
from pydantic import BaseModel, Field
class CreateTeamRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = None
join_policy: str = "OPEN" # OPEN or PRIVATE
class UpdateTeamRequest(BaseModel):
name: str | None = None
description: str | None = None
join_policy: str | None = None # OPEN or PRIVATE
class TeamResponse(BaseModel):
id: str
name: str
slug: str | None
description: str | None
is_default: bool
join_policy: str
org_id: str
member_count: int
created_at: datetime
@staticmethod
def from_db(ws, member_count: int = 0) -> "TeamResponse":
return TeamResponse(
id=ws.id,
name=ws.name,
slug=ws.slug,
description=ws.description,
is_default=ws.isDefault,
join_policy=ws.joinPolicy,
org_id=ws.orgId,
member_count=member_count,
created_at=ws.createdAt,
)
class TeamMemberResponse(BaseModel):
id: str
user_id: str
email: str
name: str | None
is_admin: bool
is_billing_manager: bool
joined_at: datetime
@staticmethod
def from_db(member) -> "TeamMemberResponse":
return TeamMemberResponse(
id=member.id,
user_id=member.userId,
email=member.User.email if member.User else "",
name=member.User.name if member.User else None,
is_admin=member.isAdmin,
is_billing_manager=member.isBillingManager,
joined_at=member.joinedAt,
)
class AddTeamMemberRequest(BaseModel):
user_id: str
is_admin: bool = False
is_billing_manager: bool = False
class UpdateTeamMemberRequest(BaseModel):
is_admin: bool | None = None
is_billing_manager: bool | None = None

View File

@@ -1,236 +0,0 @@
"""Workspace management API routes (nested under /api/orgs/{org_id}/workspaces)."""
from typing import Annotated
from autogpt_libs.auth import (
get_request_context,
requires_org_permission,
requires_team_permission,
)
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import OrgAction, TeamAction
from fastapi import APIRouter, HTTPException, Security
from . import team_db as team_db
from .team_model import (
AddTeamMemberRequest,
CreateTeamRequest,
UpdateTeamMemberRequest,
UpdateTeamRequest,
TeamMemberResponse,
TeamResponse,
)
router = APIRouter()
@router.post(
"",
summary="Create workspace",
tags=["orgs", "workspaces"],
)
async def create_team(
org_id: str,
request: CreateTeamRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.CREATE_WORKSPACES)),
],
) -> TeamResponse:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await team_db.create_team(
org_id=org_id,
name=request.name,
user_id=ctx.user_id,
description=request.description,
join_policy=request.join_policy,
)
@router.get(
"",
summary="List workspaces",
tags=["orgs", "workspaces"],
)
async def list_teams(
org_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[TeamResponse]:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await team_db.list_teams(org_id, ctx.user_id)
@router.get(
"/{ws_id}",
summary="Get workspace details",
tags=["orgs", "workspaces"],
)
async def get_team(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> TeamResponse:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await team_db.get_team(ws_id, expected_org_id=org_id)
@router.patch(
"/{ws_id}",
summary="Update workspace",
tags=["orgs", "workspaces"],
)
async def update_team(
org_id: str,
ws_id: str,
request: UpdateTeamRequest,
ctx: Annotated[
RequestContext,
Security(requires_team_permission(TeamAction.MANAGE_SETTINGS)),
],
) -> TeamResponse:
# Verify workspace belongs to org (ctx validates workspace membership)
await team_db.get_team(ws_id, expected_org_id=org_id)
return await team_db.update_team(
ws_id,
{
"name": request.name,
"description": request.description,
"joinPolicy": request.join_policy,
},
)
@router.delete(
"/{ws_id}",
summary="Delete workspace",
tags=["orgs", "workspaces"],
status_code=204,
)
async def delete_team(
org_id: str,
ws_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.MANAGE_WORKSPACES)),
],
) -> None:
await team_db.get_team(ws_id, expected_org_id=org_id)
await team_db.delete_team(ws_id)
@router.post(
"/{ws_id}/join",
summary="Self-join open workspace",
tags=["orgs", "workspaces"],
)
async def join_team(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> TeamResponse:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
return await team_db.join_team(ws_id, ctx.user_id, org_id)
@router.post(
"/{ws_id}/leave",
summary="Leave workspace",
tags=["orgs", "workspaces"],
status_code=204,
)
async def leave_team(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> None:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
await team_db.leave_team(ws_id, ctx.user_id)
# --- Members ---
@router.get(
"/{ws_id}/members",
summary="List workspace members",
tags=["orgs", "workspaces"],
)
async def list_members(
org_id: str,
ws_id: str,
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[TeamMemberResponse]:
if ctx.org_id != org_id:
raise HTTPException(403, detail="Not a member of this organization")
await team_db.get_team(ws_id, expected_org_id=org_id)
return await team_db.list_team_members(ws_id)
@router.post(
"/{ws_id}/members",
summary="Add member to workspace",
tags=["orgs", "workspaces"],
)
async def add_member(
org_id: str,
ws_id: str,
request: AddTeamMemberRequest,
ctx: Annotated[
RequestContext,
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
],
) -> TeamMemberResponse:
return await team_db.add_team_member(
ws_id=ws_id,
user_id=request.user_id,
org_id=org_id,
is_admin=request.is_admin,
is_billing_manager=request.is_billing_manager,
invited_by=ctx.user_id,
)
@router.patch(
"/{ws_id}/members/{uid}",
summary="Update workspace member role",
tags=["orgs", "workspaces"],
)
async def update_member(
org_id: str,
ws_id: str,
uid: str,
request: UpdateTeamMemberRequest,
ctx: Annotated[
RequestContext,
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
],
) -> TeamMemberResponse:
return await team_db.update_team_member(
ws_id=ws_id,
user_id=uid,
is_admin=request.is_admin,
is_billing_manager=request.is_billing_manager,
)
@router.delete(
"/{ws_id}/members/{uid}",
summary="Remove member from workspace",
tags=["orgs", "workspaces"],
status_code=204,
)
async def remove_member(
org_id: str,
ws_id: str,
uid: str,
ctx: Annotated[
RequestContext,
Security(requires_team_permission(TeamAction.MANAGE_MEMBERS)),
],
) -> None:
await team_db.remove_team_member(ws_id, uid)

View File

@@ -730,7 +730,6 @@ async def create_store_submission(
categories: list[str] = [],
changes_summary: str | None = "Initial Submission",
recommended_schedule_cron: str | None = None,
organization_id: str | None = None,
) -> store_model.StoreSubmission:
"""
Create the first (and only) store listing and thus submission as a normal user
@@ -859,7 +858,6 @@ async def create_store_submission(
"agentGraphId": graph_id,
"OwningUser": {"connect": {"id": user_id}},
"CreatorProfile": {"connect": {"userId": user_id}},
**({"owningOrgId": organization_id} if organization_id else {}),
},
}
},

View File

@@ -197,7 +197,6 @@ async def test_create_store_submission(mocker):
userId="user-id",
createdAt=now,
isActive=True,
visibility=prisma.enums.ResourceVisibility.PRIVATE,
StoreListingVersions=[],
User=mock_user,
)

View File

@@ -384,9 +384,6 @@ async def get_submissions(
async def create_submission(
submission_request: store_model.StoreSubmissionRequest,
user_id: str = Security(autogpt_libs.auth.get_user_id),
ctx: autogpt_libs.auth.RequestContext = Security(
autogpt_libs.auth.get_request_context
),
) -> store_model.StoreSubmission:
"""Submit a new marketplace listing for review"""
result = await store_db.create_store_submission(
@@ -404,7 +401,6 @@ async def create_submission(
categories=submission_request.categories,
changes_summary=submission_request.changes_summary or "Initial Submission",
recommended_schedule_cron=submission_request.recommended_schedule_cron,
organization_id=ctx.org_id,
)
return result

View File

@@ -1,279 +0,0 @@
"""Database operations for resource transfer management."""
import logging
from datetime import datetime, timezone
from backend.data.db import prisma
from backend.util.exceptions import NotFoundError
from .model import TransferResponse
logger = logging.getLogger(__name__)
_VALID_RESOURCE_TYPES = {"AgentGraph", "StoreListing"}
async def create_transfer(
source_org_id: str,
target_org_id: str,
resource_type: str,
resource_id: str,
user_id: str,
reason: str | None = None,
) -> TransferResponse:
"""Create a new transfer request from source org to target org.
Validates:
- resource_type is one of the allowed types
- source and target orgs are different
- target org exists
- the resource exists and belongs to the source org
"""
if resource_type not in _VALID_RESOURCE_TYPES:
raise ValueError(
f"Invalid resource_type '{resource_type}'. "
f"Must be one of: {', '.join(sorted(_VALID_RESOURCE_TYPES))}"
)
if source_org_id == target_org_id:
raise ValueError("Source and target organizations must be different")
target_org = await prisma.organization.find_unique(
where={"id": target_org_id}
)
if target_org is None or target_org.deletedAt is not None:
raise NotFoundError(f"Target organization {target_org_id} not found")
await _validate_resource_ownership(resource_type, resource_id, source_org_id)
tr = await prisma.transferrequest.create(
data={
"resourceType": resource_type,
"resourceId": resource_id,
"sourceOrganizationId": source_org_id,
"targetOrganizationId": target_org_id,
"initiatedByUserId": user_id,
"status": "PENDING",
"reason": reason,
}
)
return TransferResponse.from_db(tr)
async def list_transfers(org_id: str) -> list[TransferResponse]:
"""List all transfer requests where org is source OR target."""
transfers = await prisma.transferrequest.find_many(
where={
"OR": [
{"sourceOrganizationId": org_id},
{"targetOrganizationId": org_id},
]
},
order={"createdAt": "desc"},
)
return [TransferResponse.from_db(t) for t in transfers]
async def approve_transfer(
transfer_id: str,
user_id: str,
org_id: str,
) -> TransferResponse:
"""Approve a transfer from the source or target side.
- If user's active org is the source org, sets sourceApprovedByUserId.
- If user's active org is the target org, sets targetApprovedByUserId.
- Advances the status accordingly.
"""
tr = await prisma.transferrequest.find_unique(where={"id": transfer_id})
if tr is None:
raise NotFoundError(f"Transfer request {transfer_id} not found")
if tr.status in ("COMPLETED", "REJECTED"):
raise ValueError(f"Cannot approve a transfer with status '{tr.status}'")
update_data: dict = {}
if org_id == tr.sourceOrganizationId:
if tr.sourceApprovedByUserId is not None:
raise ValueError("Source organization has already approved this transfer")
update_data["sourceApprovedByUserId"] = user_id
if tr.targetApprovedByUserId is not None:
# Both sides approved — ready for execution (NOT completed yet)
update_data["status"] = "TARGET_APPROVED"
else:
update_data["status"] = "SOURCE_APPROVED"
elif org_id == tr.targetOrganizationId:
if tr.targetApprovedByUserId is not None:
raise ValueError("Target organization has already approved this transfer")
update_data["targetApprovedByUserId"] = user_id
if tr.sourceApprovedByUserId is not None:
# Both sides approved — ready for execution (NOT completed yet)
update_data["status"] = "SOURCE_APPROVED"
else:
update_data["status"] = "TARGET_APPROVED"
else:
raise ValueError(
"Your active organization is not a party to this transfer"
)
updated = await prisma.transferrequest.update(
where={"id": transfer_id},
data=update_data,
)
return TransferResponse.from_db(updated)
async def reject_transfer(
transfer_id: str,
user_id: str,
org_id: str,
) -> TransferResponse:
"""Reject a pending transfer request. Caller must be in source or target org."""
tr = await prisma.transferrequest.find_unique(where={"id": transfer_id})
if tr is None:
raise NotFoundError(f"Transfer request {transfer_id} not found")
if tr.status in ("COMPLETED", "REJECTED"):
raise ValueError(f"Cannot reject a transfer with status '{tr.status}'")
if org_id not in (tr.sourceOrganizationId, tr.targetOrganizationId):
raise ValueError(
"Your active organization is not a party to this transfer"
)
updated = await prisma.transferrequest.update(
where={"id": transfer_id},
data={"status": "REJECTED"},
)
return TransferResponse.from_db(updated)
async def execute_transfer(
transfer_id: str,
user_id: str,
) -> TransferResponse:
"""Execute an approved transfer -- move the resource to the target org.
Requires both source and target approvals. Updates the resource's
organization ownership and creates AuditLog entries for both orgs.
"""
tr = await prisma.transferrequest.find_unique(where={"id": transfer_id})
if tr is None:
raise NotFoundError(f"Transfer request {transfer_id} not found")
if tr.sourceApprovedByUserId is None or tr.targetApprovedByUserId is None:
raise ValueError(
"Transfer requires approval from both source and target organizations"
)
if tr.status == "COMPLETED":
raise ValueError("Transfer has already been executed")
if tr.status == "REJECTED":
raise ValueError("Cannot execute a rejected transfer")
await _move_resource(
resource_type=tr.resourceType,
resource_id=tr.resourceId,
target_org_id=tr.targetOrganizationId,
)
now = datetime.now(timezone.utc)
updated = await prisma.transferrequest.update(
where={"id": transfer_id},
data={"status": "COMPLETED", "completedAt": now},
)
await _create_audit_logs(
transfer=updated,
actor_user_id=user_id,
)
return TransferResponse.from_db(updated)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _validate_resource_ownership(
resource_type: str, resource_id: str, org_id: str
) -> None:
"""Verify the resource exists and belongs to the given org."""
if resource_type == "AgentGraph":
graph = await prisma.agentgraph.find_first(
where={"id": resource_id, "isActive": True}
)
if graph is None:
raise NotFoundError(f"AgentGraph '{resource_id}' not found")
if graph.organizationId != org_id:
raise ValueError(
"AgentGraph does not belong to the source organization"
)
elif resource_type == "StoreListing":
listing = await prisma.storelisting.find_unique(
where={"id": resource_id}
)
if listing is None or listing.isDeleted:
raise NotFoundError(f"StoreListing '{resource_id}' not found")
if listing.owningOrgId != org_id:
raise ValueError(
"StoreListing does not belong to the source organization"
)
async def _move_resource(
resource_type: str,
resource_id: str,
target_org_id: str,
) -> None:
"""Move the resource to the target organization."""
if resource_type == "AgentGraph":
await prisma.agentgraph.update_many(
where={"id": resource_id, "isActive": True},
data={"organizationId": target_org_id},
)
elif resource_type == "StoreListing":
await prisma.storelisting.update(
where={"id": resource_id},
data={"owningOrgId": target_org_id},
)
async def _create_audit_logs(transfer, actor_user_id: str) -> None:
"""Create audit log entries for both source and target organizations."""
common = {
"actorUserId": actor_user_id,
"entityType": "TransferRequest",
"entityId": transfer.id,
"action": "TRANSFER_EXECUTED",
"afterJson": {
"resourceType": transfer.resourceType,
"resourceId": transfer.resourceId,
"sourceOrganizationId": transfer.sourceOrganizationId,
"targetOrganizationId": transfer.targetOrganizationId,
},
"correlationId": transfer.id,
}
await prisma.auditlog.create(
data={
**common,
"organizationId": transfer.sourceOrganizationId,
"beforeJson": {"organizationId": transfer.sourceOrganizationId},
}
)
await prisma.auditlog.create(
data={
**common,
"organizationId": transfer.targetOrganizationId,
"beforeJson": {"organizationId": transfer.sourceOrganizationId},
}
)

View File

@@ -1,44 +0,0 @@
"""Pydantic request/response models for resource transfer management."""
from datetime import datetime
from pydantic import BaseModel
class CreateTransferRequest(BaseModel):
resource_type: str # "AgentGraph", "StoreListing"
resource_id: str
target_organization_id: str
reason: str | None = None
class TransferResponse(BaseModel):
id: str
resource_type: str
resource_id: str
source_organization_id: str
target_organization_id: str
initiated_by_user_id: str
status: str
source_approved_by_user_id: str | None
target_approved_by_user_id: str | None
completed_at: datetime | None
reason: str | None
created_at: datetime
@staticmethod
def from_db(tr) -> "TransferResponse":
return TransferResponse(
id=tr.id,
resource_type=tr.resourceType,
resource_id=tr.resourceId,
source_organization_id=tr.sourceOrganizationId,
target_organization_id=tr.targetOrganizationId,
initiated_by_user_id=tr.initiatedByUserId,
status=tr.status,
source_approved_by_user_id=tr.sourceApprovedByUserId,
target_approved_by_user_id=tr.targetApprovedByUserId,
completed_at=tr.completedAt,
reason=tr.reason,
created_at=tr.createdAt,
)

View File

@@ -1,105 +0,0 @@
"""Resource transfer API routes."""
from typing import Annotated
from autogpt_libs.auth import requires_org_permission
from autogpt_libs.auth.models import RequestContext
from autogpt_libs.auth.permissions import OrgAction
from fastapi import APIRouter, Security
from . import db as transfer_db
from .model import CreateTransferRequest, TransferResponse
router = APIRouter()
@router.post(
"",
summary="Create transfer request",
tags=["transfers"],
)
async def create_transfer(
request: CreateTransferRequest,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
],
) -> TransferResponse:
return await transfer_db.create_transfer(
source_org_id=ctx.org_id,
target_org_id=request.target_organization_id,
resource_type=request.resource_type,
resource_id=request.resource_id,
user_id=ctx.user_id,
reason=request.reason,
)
@router.get(
"",
summary="List transfers for active organization",
tags=["transfers"],
)
async def list_transfers(
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
],
) -> list[TransferResponse]:
return await transfer_db.list_transfers(ctx.org_id)
@router.post(
"/{transfer_id}/approve",
summary="Approve transfer request",
tags=["transfers"],
)
async def approve_transfer(
transfer_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
],
) -> TransferResponse:
return await transfer_db.approve_transfer(
transfer_id=transfer_id,
user_id=ctx.user_id,
org_id=ctx.org_id,
)
@router.post(
"/{transfer_id}/reject",
summary="Reject transfer request",
tags=["transfers"],
)
async def reject_transfer(
transfer_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
],
) -> TransferResponse:
return await transfer_db.reject_transfer(
transfer_id=transfer_id,
user_id=ctx.user_id,
org_id=ctx.org_id,
)
@router.post(
"/{transfer_id}/execute",
summary="Execute approved transfer",
tags=["transfers"],
)
async def execute_transfer(
transfer_id: str,
ctx: Annotated[
RequestContext,
Security(requires_org_permission(OrgAction.TRANSFER_RESOURCES)),
],
) -> TransferResponse:
return await transfer_db.execute_transfer(
transfer_id=transfer_id,
user_id=ctx.user_id,
)

View File

@@ -9,9 +9,8 @@ from typing import Annotated, Any, Sequence, get_args
import pydantic
import stripe
from autogpt_libs.auth import get_request_context, get_user_id, requires_user
from autogpt_libs.auth import get_user_id, requires_user
from autogpt_libs.auth.jwt_utils import get_jwt_payload
from autogpt_libs.auth.models import RequestContext
from fastapi import (
APIRouter,
Body,
@@ -438,10 +437,7 @@ async def get_graph_blocks() -> Response:
dependencies=[Security(requires_user)],
)
async def execute_graph_block(
block_id: str,
data: BlockInput,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
block_id: str, data: BlockInput, user_id: Annotated[str, Security(get_user_id)]
) -> CompletedBlockOutput:
obj = get_block(block_id)
if not obj:
@@ -487,7 +483,6 @@ async def execute_graph_block(
)
async def upload_file(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
file: UploadFile = File(...),
expiration_hours: int = 24,
) -> UploadFileResponse:
@@ -577,7 +572,6 @@ async def upload_file(
)
async def get_user_credits(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> dict[str, int]:
user_credit_model = await get_user_credit_model(user_id)
return {"credits": await user_credit_model.get_credits(user_id)}
@@ -590,9 +584,7 @@ async def get_user_credits(
dependencies=[Security(requires_user)],
)
async def request_top_up(
request: RequestTopUp,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
request: RequestTopUp, user_id: Annotated[str, Security(get_user_id)]
):
user_credit_model = await get_user_credit_model(user_id)
checkout_url = await user_credit_model.top_up_intent(user_id, request.credit_amount)
@@ -747,7 +739,6 @@ async def manage_payment_method(
)
async def get_credit_history(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
transaction_time: datetime | None = None,
transaction_type: str | None = None,
transaction_count_limit: int = 100,
@@ -772,7 +763,6 @@ async def get_credit_history(
)
async def get_refund_requests(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[RefundRequest]:
user_credit_model = await get_user_credit_model(user_id)
return await user_credit_model.get_refund_requests(user_id)
@@ -795,7 +785,6 @@ class DeleteGraphResponse(TypedDict):
)
async def list_graphs(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> Sequence[graph_db.GraphMeta]:
paginated_result = await graph_db.list_graphs_paginated(
user_id=user_id,
@@ -821,7 +810,6 @@ async def list_graphs(
async def get_graph(
graph_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
version: int | None = None,
for_export: bool = False,
) -> graph_db.GraphModel:
@@ -844,9 +832,7 @@ async def get_graph(
dependencies=[Security(requires_user)],
)
async def get_graph_all_versions(
graph_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
graph_id: str, user_id: Annotated[str, Security(get_user_id)]
) -> Sequence[graph_db.GraphModel]:
graphs = await graph_db.get_graph_all_versions(graph_id, user_id=user_id)
if not graphs:
@@ -863,18 +849,12 @@ async def get_graph_all_versions(
async def create_new_graph(
create_graph: CreateGraph,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> graph_db.GraphModel:
graph = graph_db.make_graph_model(create_graph.graph, user_id)
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
graph.validate_graph(for_run=False)
await graph_db.create_graph(
graph,
user_id=user_id,
organization_id=ctx.org_id,
team_id=ctx.team_id,
)
await graph_db.create_graph(graph, user_id=user_id)
await library_db.create_library_agent(graph, user_id)
activated_graph = await on_graph_activate(graph, user_id=user_id)
@@ -891,9 +871,7 @@ async def create_new_graph(
dependencies=[Security(requires_user)],
)
async def delete_graph(
graph_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
graph_id: str, user_id: Annotated[str, Security(get_user_id)]
) -> DeleteGraphResponse:
if active_version := await graph_db.get_graph(
graph_id=graph_id, version=None, user_id=user_id
@@ -913,7 +891,6 @@ async def update_graph(
graph_id: str,
graph: graph_db.Graph,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> graph_db.GraphModel:
if graph.id and graph.id != graph_id:
raise HTTPException(400, detail="Graph ID does not match ID in URI")
@@ -929,12 +906,7 @@ async def update_graph(
graph.reassign_ids(user_id=user_id, reassign_graph_id=False)
graph.validate_graph(for_run=False)
new_graph_version = await graph_db.create_graph(
graph,
user_id=user_id,
organization_id=ctx.org_id,
team_id=ctx.team_id,
)
new_graph_version = await graph_db.create_graph(graph, user_id=user_id)
if new_graph_version.is_active:
await library_db.update_library_agent_version_and_settings(
@@ -967,7 +939,6 @@ async def set_graph_active_version(
graph_id: str,
request_body: SetGraphActiveVersion,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
):
new_active_version = request_body.active_graph_version
new_active_graph = await graph_db.get_graph(
@@ -1011,7 +982,6 @@ async def update_graph_settings(
graph_id: str,
settings: GraphSettings,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> GraphSettings:
"""Update graph settings for the user's library agent."""
library_agent = await library_db.get_library_agent_by_graph_id(
@@ -1038,7 +1008,6 @@ async def update_graph_settings(
async def execute_graph(
graph_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
inputs: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)],
credentials_inputs: Annotated[
dict[str, CredentialsMetaInput], Body(..., embed=True, default_factory=dict)
@@ -1066,8 +1035,6 @@ async def execute_graph(
graph_version=graph_version,
graph_credentials_inputs=credentials_inputs,
dry_run=dry_run,
organization_id=ctx.org_id,
team_id=ctx.team_id,
)
# Record successful graph execution
record_graph_execution(graph_id=graph_id, status="success", user_id=user_id)
@@ -1153,7 +1120,6 @@ async def _stop_graph_run(
)
async def list_graphs_executions(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[execution_db.GraphExecutionMeta]:
paginated_result = await execution_db.get_graph_executions_paginated(
user_id=user_id,
@@ -1177,7 +1143,6 @@ async def list_graphs_executions(
async def list_graph_executions(
graph_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(
25, ge=1, le=100, description="Number of executions per page"
@@ -1235,7 +1200,6 @@ async def get_graph_execution(
graph_id: str,
graph_exec_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> execution_db.GraphExecution | execution_db.GraphExecutionWithNodes:
result = await execution_db.get_graph_execution(
user_id=user_id,
@@ -1293,7 +1257,6 @@ async def hide_activity_summary_if_disabled(
async def delete_graph_execution(
graph_exec_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> None:
await execution_db.delete_graph_execution(
graph_exec_id=graph_exec_id, user_id=user_id
@@ -1321,7 +1284,6 @@ async def enable_execution_sharing(
graph_id: Annotated[str, Path],
graph_exec_id: Annotated[str, Path],
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
_body: ShareRequest = Body(default=ShareRequest()),
) -> ShareResponse:
"""Enable sharing for a graph execution."""
@@ -1360,7 +1322,6 @@ async def disable_execution_sharing(
graph_id: Annotated[str, Path],
graph_exec_id: Annotated[str, Path],
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> None:
"""Disable sharing for a graph execution."""
# Verify the execution belongs to the user
@@ -1420,7 +1381,6 @@ class ScheduleCreationRequest(pydantic.BaseModel):
)
async def create_graph_execution_schedule(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
graph_id: str = Path(..., description="ID of the graph to schedule"),
schedule_params: ScheduleCreationRequest = Body(),
) -> scheduler.GraphExecutionJobInfo:
@@ -1451,8 +1411,6 @@ async def create_graph_execution_schedule(
input_data=schedule_params.inputs,
input_credentials=schedule_params.credentials,
user_timezone=user_timezone,
organization_id=ctx.org_id,
team_id=ctx.team_id,
)
# Convert the next_run_time back to user timezone for display
@@ -1474,7 +1432,6 @@ async def create_graph_execution_schedule(
)
async def list_graph_execution_schedules(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
graph_id: str = Path(),
) -> list[scheduler.GraphExecutionJobInfo]:
return await get_scheduler_client().get_execution_schedules(
@@ -1491,7 +1448,6 @@ async def list_graph_execution_schedules(
)
async def list_all_graphs_execution_schedules(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[scheduler.GraphExecutionJobInfo]:
return await get_scheduler_client().get_execution_schedules(user_id=user_id)
@@ -1504,7 +1460,6 @@ async def list_all_graphs_execution_schedules(
)
async def delete_graph_execution_schedule(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
schedule_id: str = Path(..., description="ID of the schedule to delete"),
) -> dict[str, Any]:
try:
@@ -1529,9 +1484,7 @@ async def delete_graph_execution_schedule(
dependencies=[Security(requires_user)],
)
async def create_api_key(
request: CreateAPIKeyRequest,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
request: CreateAPIKeyRequest, user_id: Annotated[str, Security(get_user_id)]
) -> CreateAPIKeyResponse:
"""Create a new API key"""
api_key_info, plain_text_key = await api_key_db.create_api_key(
@@ -1539,7 +1492,6 @@ async def create_api_key(
user_id=user_id,
permissions=request.permissions,
description=request.description,
organization_id=ctx.org_id if ctx.org_id else None,
)
return CreateAPIKeyResponse(api_key=api_key_info, plain_text_key=plain_text_key)
@@ -1552,7 +1504,6 @@ async def create_api_key(
)
async def get_api_keys(
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> list[api_key_db.APIKeyInfo]:
"""List all API keys for the user"""
return await api_key_db.list_user_api_keys(user_id)
@@ -1565,9 +1516,7 @@ async def get_api_keys(
dependencies=[Security(requires_user)],
)
async def get_api_key(
key_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
key_id: str, user_id: Annotated[str, Security(get_user_id)]
) -> api_key_db.APIKeyInfo:
"""Get a specific API key"""
api_key = await api_key_db.get_api_key_by_id(key_id, user_id)
@@ -1583,9 +1532,7 @@ async def get_api_key(
dependencies=[Security(requires_user)],
)
async def delete_api_key(
key_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
key_id: str, user_id: Annotated[str, Security(get_user_id)]
) -> api_key_db.APIKeyInfo:
"""Revoke an API key"""
return await api_key_db.revoke_api_key(key_id, user_id)
@@ -1598,9 +1545,7 @@ async def delete_api_key(
dependencies=[Security(requires_user)],
)
async def suspend_key(
key_id: str,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
key_id: str, user_id: Annotated[str, Security(get_user_id)]
) -> api_key_db.APIKeyInfo:
"""Suspend an API key"""
return await api_key_db.suspend_api_key(key_id, user_id)
@@ -1616,7 +1561,6 @@ async def update_permissions(
key_id: str,
request: UpdatePermissionsRequest,
user_id: Annotated[str, Security(get_user_id)],
ctx: Annotated[RequestContext, Security(get_request_context)],
) -> api_key_db.APIKeyInfo:
"""Update API key permissions"""
return await api_key_db.update_api_key_permissions(

View File

@@ -29,20 +29,15 @@ import backend.api.features.library.model
import backend.api.features.library.routes
import backend.api.features.mcp.routes as mcp_routes
import backend.api.features.oauth
import backend.api.features.orgs.invitation_routes
import backend.api.features.orgs.routes as org_routes
import backend.api.features.orgs.team_routes
import backend.api.features.otto.routes
import backend.api.features.postmark.postmark
import backend.api.features.store.model
import backend.api.features.store.routes
import backend.api.features.transfers.routes as transfer_routes
import backend.api.features.v1
import backend.api.features.workspace.routes as team_routes
import backend.api.features.workspace.routes as workspace_routes
import backend.data.block
import backend.data.db
import backend.data.graph
import backend.data.org_migration
import backend.data.user
import backend.integrations.webhooks.utils
import backend.util.service
@@ -134,7 +129,6 @@ async def lifespan_context(app: fastapi.FastAPI):
await backend.data.graph.fix_llm_provider_credentials()
await backend.data.graph.migrate_llm_models(DEFAULT_LLM_MODEL)
await backend.integrations.webhooks.utils.migrate_legacy_triggered_graphs()
await backend.data.org_migration.run_migration()
with launch_darkly_context():
yield
@@ -358,7 +352,7 @@ app.include_router(
prefix="/api/chat",
)
app.include_router(
team_routes.router,
workspace_routes.router,
tags=["workspace"],
prefix="/api/workspace",
)
@@ -372,31 +366,6 @@ app.include_router(
tags=["oauth"],
prefix="/api/oauth",
)
app.include_router(
org_routes.router,
tags=["v2", "orgs"],
prefix="/api/orgs",
)
app.include_router(
backend.api.features.orgs.team_routes.router,
tags=["v2", "orgs", "workspaces"],
prefix="/api/orgs/{org_id}/workspaces",
)
app.include_router(
backend.api.features.orgs.invitation_routes.org_router,
tags=["v2", "orgs", "invitations"],
prefix="/api/orgs/{org_id}/invitations",
)
app.include_router(
backend.api.features.orgs.invitation_routes.router,
tags=["v2", "invitations"],
prefix="/api/invitations",
)
app.include_router(
transfer_routes.router,
tags=["v2", "transfers"],
prefix="/api/transfers",
)
app.mount("/external-api", external_api)
@@ -451,22 +420,8 @@ class AgentServer(backend.util.service.AppProcess):
graph_version: Optional[int] = None,
node_input: Optional[dict[str, Any]] = None,
):
from autogpt_libs.auth.models import RequestContext
ctx = RequestContext(
user_id=user_id,
org_id="test-org",
team_id="test-workspace",
is_org_owner=True,
is_org_admin=True,
is_org_billing_manager=False,
is_team_admin=True,
is_team_billing_manager=False,
seat_status="ACTIVE",
)
return await backend.api.features.v1.execute_graph(
user_id=user_id,
ctx=ctx,
graph_id=graph_id,
graph_version=graph_version,
inputs=node_input or {},
@@ -489,22 +444,7 @@ class AgentServer(backend.util.service.AppProcess):
create_graph: backend.api.features.v1.CreateGraph,
user_id: str,
):
from autogpt_libs.auth.models import RequestContext
ctx = RequestContext(
user_id=user_id,
org_id="test-org",
team_id="test-workspace",
is_org_owner=True,
is_org_admin=True,
is_org_billing_manager=False,
is_team_admin=True,
is_team_billing_manager=False,
seat_status="ACTIVE",
)
return await backend.api.features.v1.create_new_graph(
create_graph, user_id, ctx
)
return await backend.api.features.v1.create_new_graph(create_graph, user_id)
@staticmethod
async def test_get_graph_run_status(graph_exec_id: str, user_id: str):

View File

@@ -95,8 +95,6 @@ class AgentExecutorBlock(Block):
update={"parent_execution_id": graph_exec_id},
),
dry_run=execution_context.dry_run,
organization_id=execution_context.organization_id,
team_id=execution_context.team_id,
)
logger = execution_utils.LogMetadata(

View File

@@ -162,9 +162,6 @@ async def get_chat_messages_paginated(
async def create_chat_session(
session_id: str,
user_id: str,
*,
organization_id: str | None = None,
team_id: str | None = None,
metadata: ChatSessionMetadata | None = None,
) -> ChatSessionInfo:
"""Create a new chat session in the database."""
@@ -174,9 +171,6 @@ async def create_chat_session(
credentials=SafeJson({}),
successfulAgentRuns=SafeJson({}),
successfulAgentSchedules=SafeJson({}),
# Tenancy dual-write fields
**({"organizationId": organization_id} if organization_id else {}),
**({"teamId": team_id} if team_id else {}),
metadata=SafeJson((metadata or ChatSessionMetadata()).model_dump()),
)
prisma_session = await PrismaChatSession.prisma().create(data=data)

View File

@@ -157,12 +157,6 @@ class CoPilotExecutionEntry(BaseModel):
file_ids: list[str] | None = None
"""Workspace file IDs attached to the user's message"""
organization_id: str | None = None
"""Active organization for tenant-scoped execution"""
team_id: str | None = None
"""Active workspace for tenant-scoped execution"""
mode: CopilotMode | None = None
"""Autopilot mode override: 'fast' or 'extended_thinking'. None = server default."""
@@ -185,8 +179,6 @@ async def enqueue_copilot_turn(
is_user_message: bool = True,
context: dict[str, str] | None = None,
file_ids: list[str] | None = None,
organization_id: str | None = None,
team_id: str | None = None,
mode: CopilotMode | None = None,
) -> None:
"""Enqueue a CoPilot task for processing by the executor service.
@@ -211,8 +203,6 @@ async def enqueue_copilot_turn(
is_user_message=is_user_message,
context=context,
file_ids=file_ids,
organization_id=organization_id,
team_id=team_id,
mode=mode,
)

View File

@@ -498,19 +498,12 @@ class RunAgentTool(BaseTool):
library_agent = await get_or_create_library_agent(graph, user_id)
# Execute
# Resolve org/team context for tenancy
from backend.api.features.orgs.db import get_user_default_team
org_id, team_id = await get_user_default_team(user_id)
execution = await execution_utils.add_graph_execution(
graph_id=library_agent.graph_id,
user_id=user_id,
inputs=inputs,
graph_credentials_inputs=graph_credentials,
dry_run=dry_run,
organization_id=org_id,
team_id=team_id,
)
# Track successful run (dry runs don't count against the session limit)

View File

@@ -29,9 +29,6 @@ class APIKeyInfo(APIAuthorizationInfo):
)
status: APIKeyStatus
description: Optional[str] = None
organization_id: Optional[str] = None
owner_type: Optional[str] = None
team_id_restriction: Optional[str] = None
type: Literal["api_key"] = "api_key" # type: ignore
@@ -49,9 +46,6 @@ class APIKeyInfo(APIAuthorizationInfo):
revoked_at=api_key.revokedAt,
description=api_key.description,
user_id=api_key.userId,
organization_id=api_key.organizationId,
owner_type=api_key.ownerType,
team_id_restriction=api_key.teamIdRestriction,
)
@@ -80,9 +74,6 @@ async def create_api_key(
user_id: str,
permissions: list[APIKeyPermission],
description: Optional[str] = None,
organization_id: Optional[str] = None,
owner_type: Optional[str] = None,
team_id_restriction: Optional[str] = None,
) -> tuple[APIKeyInfo, str]:
"""
Generate a new API key and store it in the database.
@@ -90,25 +81,19 @@ async def create_api_key(
"""
generated_key = keysmith.generate_key()
create_data: dict[str, object] = {
"id": str(uuid.uuid4()),
"name": name,
"head": generated_key.head,
"tail": generated_key.tail,
"hash": generated_key.hash,
"salt": generated_key.salt,
"permissions": [p for p in permissions],
"description": description,
"userId": user_id,
}
if organization_id is not None:
create_data["organizationId"] = organization_id
if owner_type is not None:
create_data["ownerType"] = owner_type
if team_id_restriction is not None:
create_data["teamIdRestriction"] = team_id_restriction
saved_key_obj = await PrismaAPIKey.prisma().create(data=create_data) # type: ignore
saved_key_obj = await PrismaAPIKey.prisma().create(
data={
"id": str(uuid.uuid4()),
"name": name,
"head": generated_key.head,
"tail": generated_key.tail,
"hash": generated_key.hash,
"salt": generated_key.salt,
"permissions": [p for p in permissions],
"description": description,
"userId": user_id,
}
)
return APIKeyInfo.from_db(saved_key_obj), generated_key.key

View File

@@ -98,14 +98,10 @@ class ExecutionContext(BaseModel):
root_execution_id: Optional[str] = None
parent_execution_id: Optional[str] = None
# File workspace (UserWorkspace — NOT the Team concept)
# Workspace
workspace_id: Optional[str] = None
session_id: Optional[str] = None
# Org/team tenancy context
organization_id: Optional[str] = None
team_id: Optional[str] = None
# -------------------------- Models -------------------------- #
@@ -521,7 +517,6 @@ async def get_graph_executions(
created_time_gte: Optional[datetime] = None,
created_time_lte: Optional[datetime] = None,
limit: Optional[int] = None,
team_id: Optional[str] = None,
) -> list[GraphExecutionMeta]:
"""⚠️ **Optional `user_id` check**: MUST USE check in user-facing endpoints."""
where_filter: AgentGraphExecutionWhereInput = {
@@ -529,10 +524,7 @@ async def get_graph_executions(
}
if graph_exec_id:
where_filter["id"] = graph_exec_id
# Prefer team_id scoping over user_id when available
if team_id:
where_filter["teamId"] = team_id
elif user_id:
if user_id:
where_filter["userId"] = user_id
if graph_id:
where_filter["agentGraphId"] = graph_id
@@ -738,8 +730,6 @@ async def create_graph_execution(
nodes_input_masks: Optional[NodesInputMasks] = None,
parent_graph_exec_id: Optional[str] = None,
is_dry_run: bool = False,
organization_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> GraphExecutionWithNodes:
"""
Create a new AgentGraphExecution record.
@@ -778,9 +768,6 @@ async def create_graph_execution(
"agentPresetId": preset_id,
"parentGraphExecutionId": parent_graph_exec_id,
**({"stats": Json({"is_dry_run": True})} if is_dry_run else {}),
# Tenancy dual-write fields
**({"organizationId": organization_id} if organization_id else {}),
**({"teamId": team_id} if team_id else {}),
},
include=GRAPH_EXECUTION_INCLUDE_WITH_NODES,
)

View File

@@ -1090,7 +1090,6 @@ async def get_graph(
for_export: bool = False,
include_subgraphs: bool = False,
skip_access_check: bool = False,
team_id: str | None = None,
) -> GraphModel | None:
"""
Retrieves a graph from the DB.
@@ -1104,18 +1103,14 @@ async def get_graph(
graph = None
# Only search graph directly on owned graph (or access check is skipped)
if skip_access_check or user_id is not None or team_id is not None:
if skip_access_check or user_id is not None:
graph_where_clause: AgentGraphWhereInput = {
"id": graph_id,
}
if version is not None:
graph_where_clause["version"] = version
# Prefer team_id scoping over user_id when both are available
if not skip_access_check:
if team_id is not None:
graph_where_clause["teamId"] = team_id
elif user_id is not None:
graph_where_clause["userId"] = user_id
if not skip_access_check and user_id is not None:
graph_where_clause["userId"] = user_id
graph = await AgentGraph.prisma().find_first(
where=graph_where_clause,
@@ -1338,19 +1333,10 @@ async def set_graph_active_version(graph_id: str, version: int, user_id: str) ->
async def get_graph_all_versions(
graph_id: str,
user_id: str,
limit: int = MAX_GRAPH_VERSIONS_FETCH,
team_id: str | None = None,
graph_id: str, user_id: str, limit: int = MAX_GRAPH_VERSIONS_FETCH
) -> list[GraphModel]:
where_clause: AgentGraphWhereInput = {"id": graph_id}
if team_id is not None:
where_clause["teamId"] = team_id
else:
where_clause["userId"] = user_id
graph_versions = await AgentGraph.prisma().find_many(
where=where_clause,
where={"id": graph_id, "userId": user_id},
order={"version": "desc"},
include=AGENT_GRAPH_INCLUDE,
take=limit,
@@ -1508,21 +1494,9 @@ async def is_graph_published_in_marketplace(graph_id: str, graph_version: int) -
return marketplace_listing is not None
async def create_graph(
graph: Graph,
user_id: str,
*,
organization_id: str | None = None,
team_id: str | None = None,
) -> GraphModel:
async def create_graph(graph: Graph, user_id: str) -> GraphModel:
async with transaction() as tx:
await __create_graph(
tx,
graph,
user_id,
organization_id=organization_id,
team_id=team_id,
)
await __create_graph(tx, graph, user_id)
if created_graph := await get_graph(graph.id, graph.version, user_id=user_id):
return created_graph
@@ -1530,14 +1504,7 @@ async def create_graph(
raise ValueError(f"Created graph {graph.id} v{graph.version} is not in DB")
async def fork_graph(
graph_id: str,
graph_version: int,
user_id: str,
*,
organization_id: str | None = None,
team_id: str | None = None,
) -> GraphModel:
async def fork_graph(graph_id: str, graph_version: int, user_id: str) -> GraphModel:
"""
Forks a graph by copying it and all its nodes and links to a new graph.
"""
@@ -1553,25 +1520,12 @@ async def fork_graph(
graph.validate_graph(for_run=False)
async with transaction() as tx:
await __create_graph(
tx,
graph,
user_id,
organization_id=organization_id,
team_id=team_id,
)
await __create_graph(tx, graph, user_id)
return graph
async def __create_graph(
tx,
graph: Graph,
user_id: str,
*,
organization_id: str | None = None,
team_id: str | None = None,
):
async def __create_graph(tx, graph: Graph, user_id: str):
graphs = [graph] + graph.sub_graphs
# Auto-increment version for any graph entry (parent or sub-graph) whose
@@ -1608,9 +1562,6 @@ async def __create_graph(
userId=user_id,
forkedFromId=graph.forked_from_id,
forkedFromVersion=graph.forked_from_version,
# Tenancy dual-write fields
organizationId=organization_id,
teamId=team_id,
)
for graph in graphs
]

View File

@@ -170,8 +170,6 @@ async def get_or_create_human_review(
input_data: SafeJsonData,
message: str,
editable: bool,
organization_id: str | None = None,
team_id: str | None = None,
) -> Optional[ReviewResult]:
"""
Get existing review or create a new pending review entry.
@@ -208,8 +206,6 @@ async def get_or_create_human_review(
"instructions": message,
"editable": editable,
"status": ReviewStatus.WAITING,
**({"organizationId": organization_id} if organization_id else {}),
**({"teamId": team_id} if team_id else {}),
},
"update": {}, # Do nothing on update - keep existing review as is
},

View File

@@ -1,235 +0,0 @@
"""Org-level credit operations.
Mirrors the UserCreditBase pattern but operates on OrgBalance and
OrgCreditTransaction tables instead of UserBalance and CreditTransaction.
All balance mutations use atomic SQL to prevent race conditions.
"""
import logging
from prisma.enums import CreditTransactionType
from pydantic import BaseModel
from backend.data.db import prisma
from backend.util.exceptions import InsufficientBalanceError
from backend.util.json import SafeJson
logger = logging.getLogger(__name__)
class _BalanceResult(BaseModel):
balance: int
async def get_org_credits(org_id: str) -> int:
"""Get the current credit balance for an organization."""
balance = await prisma.orgbalance.find_unique(where={"orgId": org_id})
return balance.balance if balance else 0
async def spend_org_credits(
org_id: str,
user_id: str,
amount: int,
team_id: str | None = None,
metadata: dict | None = None,
) -> int:
"""Atomically spend credits from the org balance.
Uses a single UPDATE ... WHERE balance >= $amount to prevent race
conditions. If the UPDATE affects 0 rows, the balance is insufficient.
Returns:
The remaining balance.
Raises:
InsufficientBalanceError: If the org doesn't have enough credits.
"""
if amount <= 0:
raise ValueError("Spend amount must be positive")
# Atomic deduct — only succeeds if balance >= amount
rows_affected = await prisma.execute_raw(
"""
UPDATE "OrgBalance"
SET "balance" = "balance" - $1, "updatedAt" = NOW()
WHERE "orgId" = $2 AND "balance" >= $1
""",
amount,
org_id,
)
if rows_affected == 0:
current = await get_org_credits(org_id)
raise InsufficientBalanceError(
f"Organization has {current} credits but needs {amount}",
user_id=user_id,
balance=current,
amount=amount,
)
# Read the new balance for the transaction record
new_balance = await get_org_credits(org_id)
# Record the transaction
tx_data: dict = {
"orgId": org_id,
"initiatedByUserId": user_id,
"amount": -amount,
"type": CreditTransactionType.USAGE,
"runningBalance": new_balance,
}
if team_id:
tx_data["teamId"] = team_id
if metadata:
tx_data["metadata"] = SafeJson(metadata)
await prisma.orgcredittransaction.create(data=tx_data)
return new_balance
async def top_up_org_credits(
org_id: str,
amount: int,
user_id: str | None = None,
metadata: dict | None = None,
) -> int:
"""Atomically add credits to the org balance.
Creates the OrgBalance row if it doesn't exist (upsert pattern via raw SQL).
Returns the new balance.
"""
if amount <= 0:
raise ValueError("Top-up amount must be positive")
# Atomic upsert — INSERT or UPDATE in one statement
await prisma.execute_raw(
"""
INSERT INTO "OrgBalance" ("orgId", "balance", "updatedAt")
VALUES ($1, $2, NOW())
ON CONFLICT ("orgId")
DO UPDATE SET "balance" = "OrgBalance"."balance" + $2, "updatedAt" = NOW()
""",
org_id,
amount,
)
new_balance = await get_org_credits(org_id)
# Record the transaction
tx_data: dict = {
"orgId": org_id,
"amount": amount,
"type": CreditTransactionType.TOP_UP,
"runningBalance": new_balance,
}
if user_id:
tx_data["initiatedByUserId"] = user_id
if metadata:
tx_data["metadata"] = SafeJson(metadata)
await prisma.orgcredittransaction.create(data=tx_data)
return new_balance
async def get_org_transaction_history(
org_id: str,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""Get credit transaction history for an organization."""
transactions = await prisma.orgcredittransaction.find_many(
where={"orgId": org_id, "isActive": True},
order={"createdAt": "desc"},
take=limit,
skip=offset,
)
return [
{
"transactionKey": t.transactionKey,
"createdAt": t.createdAt,
"amount": t.amount,
"type": t.type,
"runningBalance": t.runningBalance,
"initiatedByUserId": t.initiatedByUserId,
"teamId": t.teamId,
"metadata": t.metadata,
}
for t in transactions
]
async def get_seat_info(org_id: str) -> dict:
"""Get seat utilization for an organization."""
seats = await prisma.organizationseatassignment.find_many(
where={"organizationId": org_id}
)
total = len(seats)
active = sum(1 for s in seats if s.status == "ACTIVE")
return {
"total": total,
"active": active,
"inactive": total - active,
"seats": [
{
"userId": s.userId,
"seatType": s.seatType,
"status": s.status,
"createdAt": s.createdAt,
}
for s in seats
],
}
async def assign_seat(
org_id: str,
user_id: str,
seat_type: str = "FREE",
assigned_by: str | None = None,
) -> dict:
"""Assign a seat to a user in the organization."""
seat = await prisma.organizationseatassignment.upsert(
where={
"organizationId_userId": {
"organizationId": org_id,
"userId": user_id,
}
},
data={
"create": {
"organizationId": org_id,
"userId": user_id,
"seatType": seat_type,
"status": "ACTIVE",
"assignedByUserId": assigned_by,
},
"update": {
"seatType": seat_type,
"status": "ACTIVE",
"assignedByUserId": assigned_by,
},
},
)
return {
"userId": seat.userId,
"seatType": seat.seatType,
"status": seat.status,
}
async def unassign_seat(org_id: str, user_id: str) -> None:
"""Deactivate a user's seat assignment."""
await prisma.organizationseatassignment.update(
where={
"organizationId_userId": {
"organizationId": org_id,
"userId": user_id,
}
},
data={"status": "INACTIVE"},
)

View File

@@ -1,215 +0,0 @@
"""Tests for org-level credit operations.
Tests the atomic spend/top-up logic, edge cases, and error paths.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.data.org_credit import (
assign_seat,
get_org_credits,
get_org_transaction_history,
get_seat_info,
spend_org_credits,
top_up_org_credits,
unassign_seat,
)
from backend.util.exceptions import InsufficientBalanceError
@pytest.fixture(autouse=True)
def mock_prisma(mocker):
mock = MagicMock()
mock.orgbalance.find_unique = AsyncMock(return_value=MagicMock(balance=1000))
mock.execute_raw = AsyncMock(return_value=1) # 1 row affected = success
mock.orgcredittransaction.create = AsyncMock()
mock.orgcredittransaction.find_many = AsyncMock(return_value=[])
mock.organizationseatassignment.find_many = AsyncMock(return_value=[])
mock.organizationseatassignment.upsert = AsyncMock(
return_value=MagicMock(userId="u1", seatType="FREE", status="ACTIVE")
)
mock.organizationseatassignment.update = AsyncMock()
mocker.patch("backend.data.org_credit.prisma", mock)
return mock
class TestGetOrgCredits:
@pytest.mark.asyncio
async def test_returns_balance(self, mock_prisma):
result = await get_org_credits("org-1")
assert result == 1000
@pytest.mark.asyncio
async def test_returns_zero_when_no_balance_row(self, mock_prisma):
mock_prisma.orgbalance.find_unique = AsyncMock(return_value=None)
result = await get_org_credits("org-missing")
assert result == 0
class TestSpendOrgCredits:
@pytest.mark.asyncio
async def test_spend_success_returns_remaining(self, mock_prisma):
# execute_raw returns 1 (row affected) = success
mock_prisma.execute_raw = AsyncMock(return_value=1)
# After spend, balance reads 900
mock_prisma.orgbalance.find_unique = AsyncMock(
return_value=MagicMock(balance=900)
)
result = await spend_org_credits("org-1", "user-1", 100)
assert result == 900
mock_prisma.orgcredittransaction.create.assert_called_once()
# Verify transaction data is correct
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
assert tx_data["orgId"] == "org-1"
assert tx_data["initiatedByUserId"] == "user-1"
assert tx_data["amount"] == -100
assert tx_data["runningBalance"] == 900
@pytest.mark.asyncio
async def test_spend_insufficient_balance_raises(self, mock_prisma):
# execute_raw returns 0 = no row matched (insufficient balance)
mock_prisma.execute_raw = AsyncMock(return_value=0)
mock_prisma.orgbalance.find_unique = AsyncMock(
return_value=MagicMock(balance=50)
)
with pytest.raises(InsufficientBalanceError) as exc_info:
await spend_org_credits("org-1", "user-1", 100)
assert exc_info.value.balance == 50
assert exc_info.value.amount == 100
# Transaction should NOT be created on failure
mock_prisma.orgcredittransaction.create.assert_not_called()
@pytest.mark.asyncio
async def test_spend_zero_amount_raises(self):
with pytest.raises(ValueError, match="positive"):
await spend_org_credits("org-1", "user-1", 0)
@pytest.mark.asyncio
async def test_spend_negative_amount_raises(self):
with pytest.raises(ValueError, match="positive"):
await spend_org_credits("org-1", "user-1", -5)
@pytest.mark.asyncio
async def test_spend_records_workspace_attribution(self, mock_prisma):
mock_prisma.execute_raw = AsyncMock(return_value=1)
mock_prisma.orgbalance.find_unique = AsyncMock(
return_value=MagicMock(balance=800)
)
await spend_org_credits(
"org-1", "user-1", 200, team_id="ws-1", metadata={"block": "llm"}
)
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
assert tx_data["teamId"] == "ws-1"
assert tx_data["amount"] == -200
class TestTopUpOrgCredits:
@pytest.mark.asyncio
async def test_top_up_success(self, mock_prisma):
mock_prisma.orgbalance.find_unique = AsyncMock(
return_value=MagicMock(balance=1500)
)
result = await top_up_org_credits("org-1", 500, user_id="user-1")
assert result == 1500
mock_prisma.execute_raw.assert_called_once() # Atomic upsert
# Verify transaction data
mock_prisma.orgcredittransaction.create.assert_called_once()
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
assert tx_data["orgId"] == "org-1"
assert tx_data["amount"] == 500
assert tx_data["initiatedByUserId"] == "user-1"
mock_prisma.orgcredittransaction.create.assert_called_once()
@pytest.mark.asyncio
async def test_top_up_zero_raises(self):
with pytest.raises(ValueError, match="positive"):
await top_up_org_credits("org-1", 0)
@pytest.mark.asyncio
async def test_top_up_negative_raises(self):
with pytest.raises(ValueError, match="positive"):
await top_up_org_credits("org-1", -10)
@pytest.mark.asyncio
async def test_top_up_no_user_id_omits_from_transaction(self, mock_prisma):
mock_prisma.orgbalance.find_unique = AsyncMock(
return_value=MagicMock(balance=500)
)
await top_up_org_credits("org-1", 500)
tx_data = mock_prisma.orgcredittransaction.create.call_args[1]["data"]
assert "initiatedByUserId" not in tx_data
class TestGetOrgTransactionHistory:
@pytest.mark.asyncio
async def test_returns_transactions(self, mock_prisma):
mock_tx = MagicMock(
transactionKey="tx-1",
createdAt="2026-01-01",
amount=-100,
type="USAGE",
runningBalance=900,
initiatedByUserId="user-1",
teamId="ws-1",
metadata=None,
)
mock_prisma.orgcredittransaction.find_many = AsyncMock(return_value=[mock_tx])
result = await get_org_transaction_history("org-1", limit=10)
assert len(result) == 1
assert result[0]["amount"] == -100
assert result[0]["teamId"] == "ws-1"
class TestSeatManagement:
@pytest.mark.asyncio
async def test_get_seat_info(self, mock_prisma):
mock_prisma.organizationseatassignment.find_many = AsyncMock(
return_value=[
MagicMock(
userId="u1", seatType="PAID", status="ACTIVE", createdAt="now"
),
MagicMock(
userId="u2", seatType="FREE", status="INACTIVE", createdAt="now"
),
]
)
result = await get_seat_info("org-1")
assert result["total"] == 2
assert result["active"] == 1
assert result["inactive"] == 1
# Verify the query filtered by org
call_kwargs = mock_prisma.organizationseatassignment.find_many.call_args[1]
assert call_kwargs["where"]["organizationId"] == "org-1"
@pytest.mark.asyncio
async def test_assign_seat(self, mock_prisma):
mock_prisma.organizationseatassignment.upsert = AsyncMock(
return_value=MagicMock(userId="user-1", seatType="PAID", status="ACTIVE")
)
result = await assign_seat("org-1", "user-1", seat_type="PAID")
assert result["seatType"] == "PAID"
mock_prisma.organizationseatassignment.upsert.assert_called_once()
@pytest.mark.asyncio
async def test_unassign_seat(self, mock_prisma):
await unassign_seat("org-1", "user-1")
mock_prisma.organizationseatassignment.update.assert_called_once()
call_kwargs = mock_prisma.organizationseatassignment.update.call_args[1]
# Verify correct record targeted
where = call_kwargs["where"]["organizationId_userId"]
assert where["organizationId"] == "org-1"
assert where["userId"] == "user-1"
# Verify status set to INACTIVE
assert call_kwargs["data"]["status"] == "INACTIVE"

View File

@@ -1,463 +0,0 @@
"""
Data migration: Bootstrap personal organizations for existing users.
Creates one Organization per user, with owner membership, default workspace,
org profile, seat assignment, and org balance. Assigns all tenant-bound
resources to the user's default workspace. Idempotent — safe to run repeatedly.
Run automatically during server startup via rest_api.py lifespan.
"""
import logging
import re
import time
from typing import LiteralString
from backend.data.db import prisma
logger = logging.getLogger(__name__)
def _sanitize_slug(raw: str) -> str:
"""Convert a string to a URL-safe slug: lowercase, alphanumeric + hyphens."""
slug = re.sub(r"[^a-z0-9-]", "-", raw.lower().strip())
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or "user"
async def _resolve_unique_slug(desired: str) -> str:
"""Return *desired* if no Organization uses it yet, else append a numeric suffix."""
existing = await prisma.organization.find_unique(where={"slug": desired})
if existing is None:
# Also check aliases
alias = await prisma.organizationalias.find_unique(where={"aliasSlug": desired})
if alias is None:
return desired
# Collision — find the next available numeric suffix
for i in range(1, 10_000):
candidate = f"{desired}-{i}"
org = await prisma.organization.find_unique(where={"slug": candidate})
alias = await prisma.organizationalias.find_unique(
where={"aliasSlug": candidate}
)
if org is None and alias is None:
return candidate
raise RuntimeError(
f"Could not resolve a unique slug for '{desired}' after 10000 attempts"
)
async def create_orgs_for_existing_users() -> int:
"""Create a personal Organization for every user that lacks one.
Returns the number of orgs created.
"""
# Find users who are NOT yet an owner of any personal org
users_without_org = await prisma.query_raw(
"""
SELECT u."id", u."email", u."name", u."stripeCustomerId", u."topUpConfig",
p."username" AS profile_username, p."name" AS profile_name,
p."description" AS profile_description,
p."avatarUrl" AS profile_avatar_url,
p."links" AS profile_links
FROM "User" u
LEFT JOIN "Profile" p ON p."userId" = u."id"
WHERE NOT EXISTS (
SELECT 1 FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId"
WHERE om."userId" = u."id" AND om."isOwner" = true AND o."isPersonal" = true
)
""",
)
if not users_without_org:
logger.info("Org migration: all users already have personal orgs")
return 0
logger.info(
f"Org migration: creating personal orgs for {len(users_without_org)} users"
)
created = 0
for row in users_without_org:
user_id: str = row["id"]
email: str = row["email"]
profile_username: str | None = row.get("profile_username")
profile_name: str | None = row.get("profile_name")
user_name: str | None = row.get("name")
# Determine slug: Profile.username → sanitized User.name → email local part → user-{id[:8]}
if profile_username:
desired_slug = _sanitize_slug(profile_username)
elif user_name:
desired_slug = _sanitize_slug(user_name)
else:
local_part = email.split("@")[0] if email else ""
desired_slug = (
_sanitize_slug(local_part) if local_part else f"user-{user_id[:8]}"
)
slug = await _resolve_unique_slug(desired_slug)
display_name = profile_name or user_name or email.split("@")[0]
# Create Organization — only include optional JSON fields when non-None
org_data: dict = {
"name": display_name,
"slug": slug,
"isPersonal": True,
"bootstrapUserId": user_id,
"settings": "{}",
}
if row.get("stripeCustomerId"):
org_data["stripeCustomerId"] = row["stripeCustomerId"]
if row.get("topUpConfig"):
org_data["topUpConfig"] = row["topUpConfig"]
org = await prisma.organization.create(data=org_data)
# Create OrgMember (owner)
await prisma.orgmember.create(
data={
"Org": {"connect": {"id": org.id}},
"User": {"connect": {"id": user_id}},
"isOwner": True,
"isAdmin": True,
"status": "ACTIVE",
}
)
# Create default Team
workspace = await prisma.team.create(
data={
"name": "Default",
"Org": {"connect": {"id": org.id}},
"isDefault": True,
"joinPolicy": "OPEN",
"createdByUserId": user_id,
}
)
# Create TeamMember
await prisma.teammember.create(
data={
"Workspace": {"connect": {"id": workspace.id}},
"User": {"connect": {"id": user_id}},
"isAdmin": True,
"status": "ACTIVE",
}
)
# Create OrganizationProfile (from user's Profile if exists)
profile_data: dict = {
"Organization": {"connect": {"id": org.id}},
"username": slug,
"displayName": display_name,
}
if row.get("profile_avatar_url"):
profile_data["avatarUrl"] = row["profile_avatar_url"]
if row.get("profile_description"):
profile_data["bio"] = row["profile_description"]
if row.get("profile_links"):
profile_data["socialLinks"] = row["profile_links"]
await prisma.organizationprofile.create(data=profile_data)
# Create seat assignment (FREE seat for personal org)
await prisma.organizationseatassignment.create(
data={
"organizationId": org.id,
"userId": user_id,
"seatType": "FREE",
"status": "ACTIVE",
"assignedByUserId": user_id,
}
)
# Log if slug diverged from desired (collision resolution)
if slug != desired_slug:
logger.info(
f"Org migration: slug collision for user {user_id}"
f"desired '{desired_slug}', assigned '{slug}'"
)
created += 1
logger.info(f"Org migration: created {created} personal orgs")
return created
async def migrate_org_balances() -> int:
"""Copy UserBalance rows into OrgBalance for personal orgs that lack one.
Returns the number of balances migrated.
"""
result = await prisma.execute_raw(
"""
INSERT INTO "OrgBalance" ("orgId", "balance", "updatedAt")
SELECT o."id", ub."balance", ub."updatedAt"
FROM "UserBalance" ub
JOIN "OrgMember" om ON om."userId" = ub."userId" AND om."isOwner" = true
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
WHERE NOT EXISTS (
SELECT 1 FROM "OrgBalance" ob WHERE ob."orgId" = o."id"
)
"""
)
logger.info(f"Org migration: migrated {result} org balances")
return result
async def migrate_credit_transactions() -> int:
"""Copy CreditTransaction rows into OrgCreditTransaction for personal orgs.
Only copies transactions that haven't been migrated yet (by checking for
matching transactionKey + orgId).
Returns the number of transactions migrated.
"""
result = await prisma.execute_raw(
"""
INSERT INTO "OrgCreditTransaction"
("transactionKey", "createdAt", "orgId", "initiatedByUserId",
"amount", "type", "runningBalance", "isActive", "metadata")
SELECT
ct."transactionKey", ct."createdAt", o."id", ct."userId",
ct."amount", ct."type", ct."runningBalance", ct."isActive", ct."metadata"
FROM "CreditTransaction" ct
JOIN "OrgMember" om ON om."userId" = ct."userId" AND om."isOwner" = true
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
WHERE NOT EXISTS (
SELECT 1 FROM "OrgCreditTransaction" oct
WHERE oct."transactionKey" = ct."transactionKey" AND oct."orgId" = o."id"
)
"""
)
logger.info(f"Org migration: migrated {result} credit transactions")
return result
async def _assign_team_tenancy(table_sql: "LiteralString") -> int:
"""Assign organizationId + teamId on a single table's unassigned rows."""
return await prisma.execute_raw(table_sql)
async def assign_resources_to_teams() -> dict[str, int]:
"""Set organizationId and teamId on all tenant-bound rows that lack them.
Uses the user's personal org and its default workspace.
Returns a dict of table_name -> rows_updated.
"""
results: dict[str, int] = {}
# --- Tables needing both organizationId + teamId ---
results["AgentGraph"] = await _assign_team_tenancy(
"""
UPDATE "AgentGraph" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["AgentGraphExecution"] = await _assign_team_tenancy(
"""
UPDATE "AgentGraphExecution" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["ChatSession"] = await _assign_team_tenancy(
"""
UPDATE "ChatSession" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["AgentPreset"] = await _assign_team_tenancy(
"""
UPDATE "AgentPreset" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["LibraryAgent"] = await _assign_team_tenancy(
"""
UPDATE "LibraryAgent" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["LibraryFolder"] = await _assign_team_tenancy(
"""
UPDATE "LibraryFolder" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["IntegrationWebhook"] = await _assign_team_tenancy(
"""
UPDATE "IntegrationWebhook" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["APIKey"] = await _assign_team_tenancy(
"""
UPDATE "APIKey" t
SET "organizationId" = o."id", "teamId" = w."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
JOIN "Team" w ON w."orgId" = o."id" AND w."isDefault" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
# --- Tables needing only organizationId ---
results["BuilderSearchHistory"] = await prisma.execute_raw(
"""
UPDATE "BuilderSearchHistory" t
SET "organizationId" = o."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["PendingHumanReview"] = await prisma.execute_raw(
"""
UPDATE "PendingHumanReview" t
SET "organizationId" = o."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
WHERE t."userId" = om."userId" AND om."isOwner" = true AND t."organizationId" IS NULL
"""
)
results["StoreListingVersion"] = await prisma.execute_raw(
"""
UPDATE "StoreListingVersion" slv
SET "organizationId" = o."id"
FROM "StoreListingVersion" v
JOIN "StoreListing" sl ON sl."id" = v."storeListingId"
JOIN "OrgMember" om ON om."userId" = sl."owningUserId" AND om."isOwner" = true
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
WHERE slv."id" = v."id" AND slv."organizationId" IS NULL
"""
)
for table_name, count in results.items():
if count > 0:
logger.info(f"Org migration: assigned {count} {table_name} rows")
return results
async def migrate_store_listings() -> int:
"""Set owningOrgId on StoreListings that lack it.
Returns the number of listings migrated.
"""
result = await prisma.execute_raw(
"""
UPDATE "StoreListing" sl
SET "owningOrgId" = o."id"
FROM "OrgMember" om
JOIN "Organization" o ON o."id" = om."orgId" AND o."isPersonal" = true
WHERE sl."owningUserId" = om."userId"
AND om."isOwner" = true
AND sl."owningOrgId" IS NULL
"""
)
if result > 0:
logger.info(f"Org migration: assigned {result} store listings to orgs")
return result
async def create_store_listing_aliases() -> int:
"""Create OrganizationAlias entries for published store listings.
This ensures that marketplace URLs using the org slug continue to work.
Only creates aliases for listings whose org slug matches the user's Profile
username (which it should for personal orgs created from Profile.username).
Returns the number of aliases created.
"""
result = await prisma.execute_raw(
"""
INSERT INTO "OrganizationAlias"
("id", "organizationId", "aliasSlug", "aliasType", "createdByUserId", "isRemovable")
SELECT
gen_random_uuid(),
o."id",
p."username",
'MIGRATION',
o."bootstrapUserId",
false
FROM "StoreListing" sl
JOIN "Organization" o ON o."id" = sl."owningOrgId"
JOIN "Profile" p ON p."userId" = sl."owningUserId"
WHERE sl."owningOrgId" IS NOT NULL
AND sl."hasApprovedVersion" = true
AND o."slug" != p."username"
AND NOT EXISTS (
SELECT 1 FROM "OrganizationAlias" oa
WHERE oa."aliasSlug" = p."username"
)
"""
)
if result > 0:
logger.info(f"Org migration: created {result} store listing aliases")
return result
async def run_migration() -> None:
"""Orchestrate the full org bootstrap migration. Idempotent."""
start = time.monotonic()
logger.info("Org migration: starting personal org bootstrap")
orgs_created = await create_orgs_for_existing_users()
await migrate_org_balances()
await migrate_credit_transactions()
resource_counts = await assign_resources_to_teams()
await migrate_store_listings()
await create_store_listing_aliases()
total_resources = sum(resource_counts.values())
elapsed = time.monotonic() - start
logger.info(
f"Org migration: complete in {elapsed:.2f}s — "
f"{orgs_created} orgs created, {total_resources} resources assigned"
)

View File

@@ -1,511 +0,0 @@
"""Tests for the personal org bootstrap migration.
Tests the migration logic including slug resolution, idempotency,
and correct data mapping. Uses mocks for Prisma DB calls since the
test infrastructure does not provide a live database connection.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.data.org_migration import (
_resolve_unique_slug,
_sanitize_slug,
assign_resources_to_teams,
create_orgs_for_existing_users,
migrate_credit_transactions,
migrate_org_balances,
migrate_store_listings,
run_migration,
)
@pytest.fixture(autouse=True)
def mock_prisma(mocker):
"""Replace the prisma client in org_migration with a full mock."""
mock = MagicMock()
# Default: all find_unique calls return None (no collisions)
mock.organization.find_unique = AsyncMock(return_value=None)
mock.organizationalias.find_unique = AsyncMock(return_value=None)
mock.organization.create = AsyncMock(return_value=MagicMock(id="org-1"))
mock.orgmember.create = AsyncMock()
mock.team.create = AsyncMock(return_value=MagicMock(id="ws-1"))
mock.teammember.create = AsyncMock()
mock.organizationprofile.create = AsyncMock()
mock.organizationseatassignment.create = AsyncMock()
mock.query_raw = AsyncMock(return_value=[])
mock.execute_raw = AsyncMock(return_value=0)
mocker.patch("backend.data.org_migration.prisma", mock)
return mock
# ---------------------------------------------------------------------------
# _sanitize_slug
# ---------------------------------------------------------------------------
class TestSanitizeSlug:
def test_lowercase_and_hyphens(self):
assert _sanitize_slug("Hello World") == "hello-world"
def test_strips_special_chars(self):
assert _sanitize_slug("user@name!#$%") == "user-name"
def test_collapses_multiple_hyphens(self):
assert _sanitize_slug("a---b") == "a-b"
def test_strips_leading_trailing_hyphens(self):
assert _sanitize_slug("-hello-") == "hello"
def test_empty_string_returns_user(self):
assert _sanitize_slug("") == "user"
def test_only_special_chars_returns_user(self):
assert _sanitize_slug("@#$%") == "user"
def test_numeric_slug(self):
assert _sanitize_slug("12345") == "12345"
def test_preserves_hyphens(self):
assert _sanitize_slug("my-cool-agent") == "my-cool-agent"
def test_unicode_stripped(self):
assert _sanitize_slug("caf\u00e9-latt\u00e9") == "caf-latt"
def test_whitespace_only(self):
assert _sanitize_slug(" ") == "user"
# ---------------------------------------------------------------------------
# _resolve_unique_slug
# ---------------------------------------------------------------------------
class TestResolveUniqueSlug:
@pytest.mark.asyncio
async def test_slug_available_returns_as_is(self, mock_prisma):
result = await _resolve_unique_slug("my-org")
assert result == "my-org"
@pytest.mark.asyncio
async def test_slug_taken_by_org_gets_suffix(self, mock_prisma):
async def org_find(where):
slug = where.get("slug", "")
if slug == "taken":
return MagicMock(id="existing-org")
return None
mock_prisma.organization.find_unique = AsyncMock(side_effect=org_find)
result = await _resolve_unique_slug("taken")
assert result == "taken-1"
@pytest.mark.asyncio
async def test_slug_taken_by_alias_gets_suffix(self, mock_prisma):
async def alias_find(where):
slug = where.get("aliasSlug", "")
if slug == "aliased":
return MagicMock()
return None
mock_prisma.organizationalias.find_unique = AsyncMock(side_effect=alias_find)
result = await _resolve_unique_slug("aliased")
assert result == "aliased-1"
@pytest.mark.asyncio
async def test_multiple_collisions_increments(self, mock_prisma):
async def org_find(where):
slug = where.get("slug", "")
if slug in ("x", "x-1", "x-2"):
return MagicMock(id="existing")
return None
mock_prisma.organization.find_unique = AsyncMock(side_effect=org_find)
result = await _resolve_unique_slug("x")
assert result == "x-3"
# ---------------------------------------------------------------------------
# create_orgs_for_existing_users
# ---------------------------------------------------------------------------
class TestCreateOrgsForExistingUsers:
@pytest.mark.asyncio
async def test_no_users_without_org_is_noop(self, mock_prisma):
result = await create_orgs_for_existing_users()
assert result == 0
@pytest.mark.asyncio
async def test_user_with_profile_gets_profile_username_slug(self, mock_prisma):
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-1",
"email": "alice@example.com",
"name": "Alice",
"stripeCustomerId": "cus_123",
"topUpConfig": None,
"profile_username": "alice",
"profile_name": "Alice Smith",
"profile_description": "A developer",
"profile_avatar_url": "https://example.com/avatar.png",
"profile_links": ["https://github.com/alice"],
},
]
)
result = await create_orgs_for_existing_users()
assert result == 1
# Verify org was created with profile-derived slug
mock_prisma.organization.create.assert_called_once()
create_data = mock_prisma.organization.create.call_args[1]["data"]
assert create_data["slug"] == "alice"
assert create_data["name"] == "Alice Smith"
assert create_data["isPersonal"] is True
assert create_data["stripeCustomerId"] == "cus_123"
assert create_data["bootstrapUserId"] == "user-1"
# Verify workspace created
mock_prisma.team.create.assert_called_once()
ws_data = mock_prisma.team.create.call_args[1]["data"]
assert ws_data["name"] == "Default"
assert ws_data["isDefault"] is True
assert ws_data["joinPolicy"] == "OPEN"
@pytest.mark.asyncio
async def test_user_without_profile_uses_email_slug(self, mock_prisma):
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-2",
"email": "bob@company.org",
"name": None,
"stripeCustomerId": None,
"topUpConfig": None,
"profile_username": None,
"profile_name": None,
"profile_description": None,
"profile_avatar_url": None,
"profile_links": None,
},
]
)
result = await create_orgs_for_existing_users()
assert result == 1
create_data = mock_prisma.organization.create.call_args[1]["data"]
assert create_data["slug"] == "bob"
assert create_data["name"] == "bob"
@pytest.mark.asyncio
async def test_user_with_name_no_profile_uses_name_slug(self, mock_prisma):
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-3",
"email": "charlie@example.com",
"name": "Charlie Brown",
"stripeCustomerId": None,
"topUpConfig": None,
"profile_username": None,
"profile_name": None,
"profile_description": None,
"profile_avatar_url": None,
"profile_links": None,
},
]
)
result = await create_orgs_for_existing_users()
assert result == 1
create_data = mock_prisma.organization.create.call_args[1]["data"]
assert create_data["slug"] == "charlie-brown"
assert create_data["name"] == "Charlie Brown"
@pytest.mark.asyncio
async def test_user_with_empty_email_uses_id_slug(self, mock_prisma):
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "abcdef12-3456-7890-abcd-ef1234567890",
"email": "",
"name": None,
"stripeCustomerId": None,
"topUpConfig": None,
"profile_username": None,
"profile_name": None,
"profile_description": None,
"profile_avatar_url": None,
"profile_links": None,
},
]
)
result = await create_orgs_for_existing_users()
assert result == 1
create_data = mock_prisma.organization.create.call_args[1]["data"]
assert create_data["slug"] == "user-abcdef12"
@pytest.mark.asyncio
async def test_stripe_customer_id_included_when_present(self, mock_prisma):
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-stripe",
"email": "stripe@test.com",
"name": "Stripe User",
"stripeCustomerId": "cus_abc123",
"topUpConfig": '{"amount": 1000}',
"profile_username": "stripeuser",
"profile_name": "Stripe User",
"profile_description": None,
"profile_avatar_url": None,
"profile_links": None,
},
]
)
result = await create_orgs_for_existing_users()
assert result == 1
create_data = mock_prisma.organization.create.call_args[1]["data"]
assert create_data["stripeCustomerId"] == "cus_abc123"
assert create_data["topUpConfig"] == '{"amount": 1000}'
@pytest.mark.asyncio
async def test_stripe_fields_omitted_when_none(self, mock_prisma):
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-no-stripe",
"email": "nostripe@test.com",
"name": None,
"stripeCustomerId": None,
"topUpConfig": None,
"profile_username": "nostripe",
"profile_name": None,
"profile_description": None,
"profile_avatar_url": None,
"profile_links": None,
},
]
)
result = await create_orgs_for_existing_users()
assert result == 1
create_data = mock_prisma.organization.create.call_args[1]["data"]
assert "stripeCustomerId" not in create_data
assert "topUpConfig" not in create_data
@pytest.mark.asyncio
async def test_org_profile_omits_none_optional_fields(self, mock_prisma):
"""Profile creation should not pass None for optional JSON fields."""
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-minimal",
"email": "minimal@test.com",
"name": "Min",
"stripeCustomerId": None,
"topUpConfig": None,
"profile_username": "minimal",
"profile_name": "Min",
"profile_description": None,
"profile_avatar_url": None,
"profile_links": None,
},
]
)
await create_orgs_for_existing_users()
profile_data = mock_prisma.organizationprofile.create.call_args[1]["data"]
assert "avatarUrl" not in profile_data
assert "bio" not in profile_data
assert "socialLinks" not in profile_data
assert profile_data["username"] == "minimal"
assert profile_data["displayName"] == "Min"
@pytest.mark.asyncio
async def test_creates_all_required_records(self, mock_prisma):
"""Verify the full set of records created per user."""
mock_prisma.query_raw = AsyncMock(
return_value=[
{
"id": "user-full",
"email": "full@test.com",
"name": "Full User",
"stripeCustomerId": None,
"topUpConfig": None,
"profile_username": "fulluser",
"profile_name": "Full User",
"profile_description": "Bio here",
"profile_avatar_url": "https://example.com/avatar.png",
"profile_links": ["https://github.com/fulluser"],
},
]
)
await create_orgs_for_existing_users()
# Verify all 6 records created
mock_prisma.organization.create.assert_called_once()
mock_prisma.orgmember.create.assert_called_once()
mock_prisma.team.create.assert_called_once()
mock_prisma.teammember.create.assert_called_once()
mock_prisma.organizationprofile.create.assert_called_once()
mock_prisma.organizationseatassignment.create.assert_called_once()
# Verify OrgMember is owner+admin
member_data = mock_prisma.orgmember.create.call_args[1]["data"]
assert member_data["isOwner"] is True
assert member_data["isAdmin"] is True
# Verify workspace is default+open
ws_data = mock_prisma.team.create.call_args[1]["data"]
assert ws_data["isDefault"] is True
assert ws_data["joinPolicy"] == "OPEN"
# Verify seat is FREE+ACTIVE
seat_data = mock_prisma.organizationseatassignment.create.call_args[1]["data"]
assert seat_data["seatType"] == "FREE"
assert seat_data["status"] == "ACTIVE"
# ---------------------------------------------------------------------------
# migrate_org_balances
# ---------------------------------------------------------------------------
class TestMigrateOrgBalances:
@pytest.mark.asyncio
async def test_returns_count(self, mock_prisma):
mock_prisma.execute_raw = AsyncMock(return_value=5)
result = await migrate_org_balances()
assert result == 5
# ---------------------------------------------------------------------------
# migrate_credit_transactions
# ---------------------------------------------------------------------------
class TestMigrateCreditTransactions:
@pytest.mark.asyncio
async def test_returns_count(self, mock_prisma):
mock_prisma.execute_raw = AsyncMock(return_value=42)
result = await migrate_credit_transactions()
assert result == 42
# ---------------------------------------------------------------------------
# assign_resources_to_teams
# ---------------------------------------------------------------------------
class TestAssignResources:
@pytest.mark.asyncio
async def test_updates_all_tables(self, mock_prisma, mocker):
mocker.patch(
"backend.data.org_migration._assign_team_tenancy",
new_callable=AsyncMock,
return_value=10,
)
mock_prisma.execute_raw = AsyncMock(return_value=10)
result = await assign_resources_to_teams()
# 8 tables with workspace + 3 tables org-only = 11 entries
assert len(result) == 11
assert result["AgentGraph"] == 10
assert result["ChatSession"] == 10
assert result["BuilderSearchHistory"] == 10
assert result["PendingHumanReview"] == 10
assert result["StoreListingVersion"] == 10
@pytest.mark.asyncio
async def test_zero_updates_still_returns(self, mock_prisma, mocker):
mocker.patch(
"backend.data.org_migration._assign_team_tenancy",
new_callable=AsyncMock,
return_value=0,
)
mock_prisma.execute_raw = AsyncMock(return_value=0)
result = await assign_resources_to_teams()
assert all(v == 0 for v in result.values())
# ---------------------------------------------------------------------------
# migrate_store_listings
# ---------------------------------------------------------------------------
class TestMigrateStoreListings:
@pytest.mark.asyncio
async def test_returns_count(self, mock_prisma):
mock_prisma.execute_raw = AsyncMock(return_value=3)
result = await migrate_store_listings()
assert result == 3
# ---------------------------------------------------------------------------
# run_migration (orchestrator)
# ---------------------------------------------------------------------------
class TestRunMigration:
@pytest.mark.asyncio
async def test_calls_all_steps_in_order(self, mocker):
calls: list[str] = []
mocker.patch(
"backend.data.org_migration.create_orgs_for_existing_users",
new_callable=lambda: lambda: _track(calls, "create_orgs", 1),
)
mocker.patch(
"backend.data.org_migration.migrate_org_balances",
new_callable=lambda: lambda: _track(calls, "balances", 0),
)
mocker.patch(
"backend.data.org_migration.migrate_credit_transactions",
new_callable=lambda: lambda: _track(calls, "credits", 0),
)
mocker.patch(
"backend.data.org_migration.assign_resources_to_teams",
new_callable=lambda: lambda: _track(
calls, "assign_resources", {"AgentGraph": 5}
),
)
mocker.patch(
"backend.data.org_migration.migrate_store_listings",
new_callable=lambda: lambda: _track(calls, "store_listings", 0),
)
mocker.patch(
"backend.data.org_migration.create_store_listing_aliases",
new_callable=lambda: lambda: _track(calls, "aliases", 0),
)
await run_migration()
assert calls == [
"create_orgs",
"balances",
"credits",
"assign_resources",
"store_listings",
"aliases",
]
async def _track(calls: list[str], name: str, result):
calls.append(name)
return result

View File

@@ -159,8 +159,6 @@ async def _execute_graph(**kwargs):
graph_version=args.graph_version,
inputs=args.input_data,
graph_credentials_inputs=args.input_credentials,
organization_id=args.organization_id,
team_id=args.team_id,
)
await db.increment_onboarding_runs(args.user_id)
elapsed = asyncio.get_event_loop().time() - start_time
@@ -392,8 +390,6 @@ class GraphExecutionJobArgs(BaseModel):
cron: str
input_data: GraphInput
input_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict)
organization_id: str | None = None
team_id: str | None = None
class GraphExecutionJobInfo(GraphExecutionJobArgs):
@@ -671,8 +667,6 @@ class Scheduler(AppService):
input_credentials: dict[str, CredentialsMetaInput],
name: Optional[str] = None,
user_timezone: str | None = None,
organization_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> GraphExecutionJobInfo:
# Validate the graph before scheduling to prevent runtime failures
# We don't need the return value, just want the validation to run
@@ -709,8 +703,6 @@ class Scheduler(AppService):
cron=cron,
input_data=input_data,
input_credentials=input_credentials,
organization_id=organization_id,
team_id=team_id,
)
job = self.scheduler.add_job(
execute_graph,

View File

@@ -15,7 +15,7 @@ from backend.data import graph as graph_db
from backend.data import human_review as human_review_db
from backend.data import onboarding as onboarding_db
from backend.data import user as user_db
from backend.data import workspace as team_db
from backend.data import workspace as workspace_db
# Import dynamic field utilities from centralized location
from backend.data.block import BlockInput, BlockOutputEntry
@@ -869,8 +869,6 @@ async def add_graph_execution(
execution_context: Optional[ExecutionContext] = None,
graph_exec_id: Optional[str] = None,
dry_run: bool = False,
organization_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> GraphExecutionWithNodes:
"""
Adds a graph execution to the queue and returns the execution entry.
@@ -897,7 +895,7 @@ async def add_graph_execution(
udb = user_db
gdb = graph_db
odb = onboarding_db
wdb = team_db
wdb = workspace_db
else:
edb = udb = gdb = odb = wdb = get_database_manager_async_client()
@@ -955,8 +953,6 @@ async def add_graph_execution(
preset_id=preset_id,
parent_graph_exec_id=parent_exec_id,
is_dry_run=dry_run,
organization_id=organization_id,
team_id=team_id,
)
logger.info(
@@ -987,7 +983,7 @@ async def add_graph_execution(
# Execution hierarchy
root_execution_id=graph_exec.id,
# Workspace (enables workspace:// file resolution in blocks)
team_id=workspace.id,
workspace_id=workspace.id,
)
try:

View File

@@ -1,175 +0,0 @@
"""Scoped credential store using the IntegrationCredential table.
Provides the new credential resolution path (USER → WORKSPACE → ORG)
using the IntegrationCredential table introduced in PR1. During the
dual-read transition period, callers should try this store first and
fall back to the legacy IntegrationCredentialsStore.
This store is used alongside the existing credentials_store.py which
reads from the User.integrations encrypted blob.
"""
import logging
from typing import Optional
from backend.data.db import prisma
from backend.util.encryption import JSONCryptor
logger = logging.getLogger(__name__)
_cryptor = JSONCryptor()
async def get_scoped_credentials(
user_id: str,
organization_id: str,
team_id: str | None = None,
provider: str | None = None,
) -> list[dict]:
"""Get credentials visible to the user in the current org/workspace context.
Resolution order (per plan 3D):
1. USER credentials created by this user in this org
2. WORKSPACE credentials for the active workspace (if workspace is set)
3. ORG credentials for the active org
Returns a list of credential metadata dicts (not decrypted payloads).
"""
results: list[dict] = []
# 1. User-scoped credentials
user_where: dict = {
"organizationId": organization_id,
"ownerType": "USER",
"ownerId": user_id,
"status": "active",
}
if provider:
user_where["provider"] = provider
user_creds = await prisma.integrationcredential.find_many(where=user_where)
for c in user_creds:
results.append(_cred_to_metadata(c, scope="USER"))
# 2. Workspace-scoped credentials (only if workspace is active)
if team_id:
ws_where: dict = {
"organizationId": organization_id,
"ownerType": "TEAM",
"ownerId": team_id,
"status": "active",
}
if provider:
ws_where["provider"] = provider
ws_creds = await prisma.integrationcredential.find_many(where=ws_where)
for c in ws_creds:
results.append(_cred_to_metadata(c, scope="TEAM"))
# 3. Org-scoped credentials
org_where: dict = {
"organizationId": organization_id,
"ownerType": "ORG",
"ownerId": organization_id,
"status": "active",
}
if provider:
org_where["provider"] = provider
org_creds = await prisma.integrationcredential.find_many(where=org_where)
for c in org_creds:
results.append(_cred_to_metadata(c, scope="ORG"))
return results
async def get_credential_by_id(
credential_id: str,
user_id: str,
organization_id: str,
team_id: str | None = None,
decrypt: bool = False,
) -> Optional[dict]:
"""Get a specific credential by ID if the user has access.
Access rules:
- USER creds: only the creating user can access
- WORKSPACE creds: any workspace member can access (verified by caller)
- ORG creds: any org member can access (verified by caller)
"""
cred = await prisma.integrationcredential.find_unique(where={"id": credential_id})
if cred is None or cred.organizationId != organization_id:
return None
# Access check
if cred.ownerType == "USER" and cred.createdByUserId != user_id:
return None
result = _cred_to_metadata(cred, scope=cred.ownerType)
if decrypt:
result["payload"] = _cryptor.decrypt(cred.encryptedPayload)
return result
async def create_credential(
organization_id: str,
owner_type: str, # USER, WORKSPACE, ORG
owner_id: str, # userId, workspaceId, or orgId
provider: str,
credential_type: str,
display_name: str,
payload: dict,
user_id: str,
expires_at=None,
metadata: dict | None = None,
) -> dict:
"""Create a new scoped credential."""
encrypted = _cryptor.encrypt(payload)
cred = await prisma.integrationcredential.create(
data={
"organizationId": organization_id,
"ownerType": owner_type,
"ownerId": owner_id,
"provider": provider,
"credentialType": credential_type,
"displayName": display_name,
"encryptedPayload": encrypted,
"createdByUserId": user_id,
"expiresAt": expires_at,
"metadata": metadata,
}
)
return _cred_to_metadata(cred, scope=owner_type)
async def delete_credential(
credential_id: str, user_id: str, organization_id: str
) -> None:
"""Soft-delete a credential by setting status to 'revoked'."""
cred = await prisma.integrationcredential.find_unique(where={"id": credential_id})
if cred is None or cred.organizationId != organization_id:
raise ValueError(f"Credential {credential_id} not found")
# Only the creator or an admin can delete (admin check done at route level)
await prisma.integrationcredential.update(
where={"id": credential_id},
data={"status": "revoked"},
)
def _cred_to_metadata(cred, scope: str) -> dict:
"""Convert a Prisma IntegrationCredential to a metadata dict."""
return {
"id": cred.id,
"provider": cred.provider,
"credentialType": cred.credentialType,
"displayName": cred.displayName,
"scope": scope,
"createdByUserId": cred.createdByUserId,
"lastUsedAt": cred.lastUsedAt,
"expiresAt": cred.expiresAt,
"createdAt": cred.createdAt,
}

View File

@@ -1,553 +0,0 @@
-- CreateEnum
CREATE TYPE "TeamJoinPolicy" AS ENUM ('OPEN', 'PRIVATE');
-- CreateEnum
CREATE TYPE "ResourceVisibility" AS ENUM ('PRIVATE', 'TEAM', 'ORG');
-- CreateEnum
CREATE TYPE "CredentialScope" AS ENUM ('USER', 'TEAM', 'ORG');
-- CreateEnum
CREATE TYPE "OrgAliasType" AS ENUM ('MIGRATION', 'RENAME', 'MANUAL');
-- CreateEnum
CREATE TYPE "OrgMemberStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED');
-- CreateEnum
CREATE TYPE "SeatType" AS ENUM ('FREE', 'PAID');
-- CreateEnum
CREATE TYPE "SeatStatus" AS ENUM ('ACTIVE', 'INACTIVE', 'PENDING');
-- CreateEnum
CREATE TYPE "TransferStatus" AS ENUM ('PENDING', 'SOURCE_APPROVED', 'TARGET_APPROVED', 'COMPLETED', 'REJECTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "CredentialOwnerType" AS ENUM ('USER', 'TEAM', 'ORG');
-- AlterTable
ALTER TABLE "BuilderSearchHistory" ADD COLUMN "organizationId" TEXT;
-- AlterTable
ALTER TABLE "ChatSession" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "AgentGraph" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "AgentPreset" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "UserNotificationBatch" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT;
-- AlterTable
ALTER TABLE "LibraryAgent" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "LibraryFolder" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "AgentGraphExecution" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "PendingHumanReview" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT;
-- AlterTable
ALTER TABLE "IntegrationWebhook" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "teamId" TEXT,
ADD COLUMN "visibility" "ResourceVisibility" NOT NULL DEFAULT 'PRIVATE';
-- AlterTable
ALTER TABLE "StoreListing" ADD COLUMN "owningOrgId" TEXT;
-- AlterTable
ALTER TABLE "StoreListingVersion" ADD COLUMN "organizationId" TEXT;
-- AlterTable
ALTER TABLE "APIKey" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "ownerType" "CredentialOwnerType",
ADD COLUMN "teamId" TEXT,
ADD COLUMN "teamIdRestriction" TEXT;
-- AlterTable
ALTER TABLE "OAuthApplication" ADD COLUMN "organizationId" TEXT,
ADD COLUMN "ownerType" "CredentialOwnerType",
ADD COLUMN "teamIdRestriction" TEXT;
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"avatarUrl" TEXT,
"description" TEXT,
"isPersonal" BOOLEAN NOT NULL DEFAULT false,
"settings" JSONB NOT NULL DEFAULT '{}',
"stripeCustomerId" TEXT,
"stripeSubscriptionId" TEXT,
"topUpConfig" JSONB,
"archivedAt" TIMESTAMP(3),
"deletedAt" TIMESTAMP(3),
"bootstrapUserId" TEXT,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganizationAlias" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"aliasSlug" TEXT NOT NULL,
"aliasType" "OrgAliasType" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdByUserId" TEXT,
"removedAt" TIMESTAMP(3),
"removedByUserId" TEXT,
"isRemovable" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "OrganizationAlias_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganizationProfile" (
"organizationId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"displayName" TEXT,
"avatarUrl" TEXT,
"bio" TEXT,
"socialLinks" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationProfile_pkey" PRIMARY KEY ("organizationId")
);
-- CreateTable
CREATE TABLE "OrgMember" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"isOwner" BOOLEAN NOT NULL DEFAULT false,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
"status" "OrgMemberStatus" NOT NULL DEFAULT 'ACTIVE',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"invitedByUserId" TEXT,
CONSTRAINT "OrgMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrgInvitation" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"orgId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"targetUserId" TEXT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
"token" TEXT NOT NULL,
"tokenHash" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"invitedByUserId" TEXT NOT NULL,
"teamIds" TEXT[],
CONSTRAINT "OrgInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT,
"description" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"joinPolicy" "TeamJoinPolicy" NOT NULL DEFAULT 'OPEN',
"orgId" TEXT NOT NULL,
"archivedAt" TIMESTAMP(3),
"createdByUserId" TEXT,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
"status" "OrgMemberStatus" NOT NULL DEFAULT 'ACTIVE',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"invitedByUserId" TEXT,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamInvite" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"teamId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"targetUserId" TEXT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBillingManager" BOOLEAN NOT NULL DEFAULT false,
"token" TEXT NOT NULL,
"tokenHash" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"invitedByUserId" TEXT NOT NULL,
CONSTRAINT "TeamInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganizationSubscription" (
"organizationId" TEXT NOT NULL,
"planCode" TEXT,
"planTier" TEXT,
"stripeCustomerId" TEXT,
"stripeSubscriptionId" TEXT,
"status" TEXT NOT NULL DEFAULT 'active',
"renewalAt" TIMESTAMP(3),
"cancelAt" TIMESTAMP(3),
"entitlements" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationSubscription_pkey" PRIMARY KEY ("organizationId")
);
-- CreateTable
CREATE TABLE "OrganizationSeatAssignment" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"seatType" "SeatType" NOT NULL DEFAULT 'FREE',
"status" "SeatStatus" NOT NULL DEFAULT 'ACTIVE',
"assignedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationSeatAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrgBalance" (
"orgId" TEXT NOT NULL,
"balance" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrgBalance_pkey" PRIMARY KEY ("orgId")
);
-- CreateTable
CREATE TABLE "OrgCreditTransaction" (
"transactionKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"orgId" TEXT NOT NULL,
"initiatedByUserId" TEXT,
"teamId" TEXT,
"amount" INTEGER NOT NULL,
"type" "CreditTransactionType" NOT NULL,
"runningBalance" INTEGER,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"metadata" JSONB,
CONSTRAINT "OrgCreditTransaction_pkey" PRIMARY KEY ("transactionKey","orgId")
);
-- CreateTable
CREATE TABLE "TransferRequest" (
"id" TEXT NOT NULL,
"resourceType" TEXT NOT NULL,
"resourceId" TEXT NOT NULL,
"sourceOrganizationId" TEXT NOT NULL,
"targetOrganizationId" TEXT NOT NULL,
"initiatedByUserId" TEXT NOT NULL,
"status" "TransferStatus" NOT NULL DEFAULT 'PENDING',
"sourceApprovedByUserId" TEXT,
"targetApprovedByUserId" TEXT,
"completedAt" TIMESTAMP(3),
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT,
"teamId" TEXT,
"actorUserId" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT,
"action" TEXT NOT NULL,
"beforeJson" JSONB,
"afterJson" JSONB,
"correlationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IntegrationCredential" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"ownerType" "CredentialOwnerType" NOT NULL,
"ownerId" TEXT NOT NULL,
"teamId" TEXT,
"provider" TEXT NOT NULL,
"credentialType" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"encryptedPayload" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'active',
"metadata" JSONB,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntegrationCredential_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationAlias_aliasSlug_key" ON "OrganizationAlias"("aliasSlug");
-- CreateIndex
CREATE INDEX "OrganizationAlias_aliasSlug_idx" ON "OrganizationAlias"("aliasSlug");
-- CreateIndex
CREATE INDEX "OrganizationAlias_organizationId_idx" ON "OrganizationAlias"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationProfile_username_key" ON "OrganizationProfile"("username");
-- CreateIndex
CREATE INDEX "OrgMember_userId_idx" ON "OrgMember"("userId");
-- CreateIndex
CREATE INDEX "OrgMember_orgId_status_idx" ON "OrgMember"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "OrgMember_orgId_userId_key" ON "OrgMember"("orgId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "OrgInvitation_token_key" ON "OrgInvitation"("token");
-- CreateIndex
CREATE INDEX "OrgInvitation_email_idx" ON "OrgInvitation"("email");
-- CreateIndex
CREATE INDEX "OrgInvitation_token_idx" ON "OrgInvitation"("token");
-- CreateIndex
CREATE INDEX "OrgInvitation_orgId_idx" ON "OrgInvitation"("orgId");
-- CreateIndex
CREATE INDEX "Team_orgId_isDefault_idx" ON "Team"("orgId", "isDefault");
-- CreateIndex
CREATE INDEX "Team_orgId_joinPolicy_idx" ON "Team"("orgId", "joinPolicy");
-- CreateIndex
CREATE UNIQUE INDEX "Team_orgId_name_key" ON "Team"("orgId", "name");
-- CreateIndex
CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId");
-- CreateIndex
CREATE INDEX "TeamMember_teamId_status_idx" ON "TeamMember"("teamId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamId_userId_key" ON "TeamMember"("teamId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamInvite_token_key" ON "TeamInvite"("token");
-- CreateIndex
CREATE INDEX "TeamInvite_email_idx" ON "TeamInvite"("email");
-- CreateIndex
CREATE INDEX "TeamInvite_token_idx" ON "TeamInvite"("token");
-- CreateIndex
CREATE INDEX "TeamInvite_teamId_idx" ON "TeamInvite"("teamId");
-- CreateIndex
CREATE INDEX "OrganizationSeatAssignment_organizationId_status_idx" ON "OrganizationSeatAssignment"("organizationId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationSeatAssignment_organizationId_userId_key" ON "OrganizationSeatAssignment"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "OrgCreditTransaction_orgId_createdAt_idx" ON "OrgCreditTransaction"("orgId", "createdAt");
-- CreateIndex
CREATE INDEX "OrgCreditTransaction_initiatedByUserId_idx" ON "OrgCreditTransaction"("initiatedByUserId");
-- CreateIndex
CREATE INDEX "TransferRequest_sourceOrganizationId_idx" ON "TransferRequest"("sourceOrganizationId");
-- CreateIndex
CREATE INDEX "TransferRequest_targetOrganizationId_idx" ON "TransferRequest"("targetOrganizationId");
-- CreateIndex
CREATE INDEX "TransferRequest_status_idx" ON "TransferRequest"("status");
-- CreateIndex
CREATE INDEX "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_createdAt_idx" ON "AuditLog"("actorUserId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "IntegrationCredential_organizationId_ownerType_provider_idx" ON "IntegrationCredential"("organizationId", "ownerType", "provider");
-- CreateIndex
CREATE INDEX "IntegrationCredential_ownerId_ownerType_idx" ON "IntegrationCredential"("ownerId", "ownerType");
-- CreateIndex
CREATE INDEX "IntegrationCredential_createdByUserId_idx" ON "IntegrationCredential"("createdByUserId");
-- CreateIndex
CREATE INDEX "ChatSession_teamId_updatedAt_idx" ON "ChatSession"("teamId", "updatedAt");
-- CreateIndex
CREATE INDEX "AgentGraph_teamId_isActive_id_version_idx" ON "AgentGraph"("teamId", "isActive", "id", "version");
-- CreateIndex
CREATE INDEX "AgentPreset_teamId_idx" ON "AgentPreset"("teamId");
-- CreateIndex
CREATE INDEX "LibraryAgent_teamId_idx" ON "LibraryAgent"("teamId");
-- CreateIndex
CREATE INDEX "AgentGraphExecution_teamId_isDeleted_createdAt_idx" ON "AgentGraphExecution"("teamId", "isDeleted", "createdAt");
-- CreateIndex
CREATE INDEX "StoreListing_owningOrgId_idx" ON "StoreListing"("owningOrgId");
-- CreateIndex
CREATE INDEX "APIKey_teamId_idx" ON "APIKey"("teamId");
-- AddForeignKey
ALTER TABLE "ChatSession" ADD CONSTRAINT "ChatSession_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraph" ADD CONSTRAINT "AgentGraph_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraphExecution" ADD CONSTRAINT "AgentGraphExecution_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IntegrationWebhook" ADD CONSTRAINT "IntegrationWebhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_owningOrgId_fkey" FOREIGN KEY ("owningOrgId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APIKey" ADD CONSTRAINT "APIKey_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationAlias" ADD CONSTRAINT "OrganizationAlias_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationProfile" ADD CONSTRAINT "OrganizationProfile_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgMember" ADD CONSTRAINT "OrgMember_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgMember" ADD CONSTRAINT "OrgMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgInvitation" ADD CONSTRAINT "OrgInvitation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamInvite" ADD CONSTRAINT "TeamInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationSubscription" ADD CONSTRAINT "OrganizationSubscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationSeatAssignment" ADD CONSTRAINT "OrganizationSeatAssignment_organizationId_userId_fkey" FOREIGN KEY ("organizationId", "userId") REFERENCES "OrgMember"("orgId", "userId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationSeatAssignment" ADD CONSTRAINT "OrganizationSeatAssignment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgBalance" ADD CONSTRAINT "OrgBalance_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrgCreditTransaction" ADD CONSTRAINT "OrgCreditTransaction_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE NO ACTION ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TransferRequest" ADD CONSTRAINT "TransferRequest_sourceOrganizationId_fkey" FOREIGN KEY ("sourceOrganizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TransferRequest" ADD CONSTRAINT "TransferRequest_targetOrganizationId_fkey" FOREIGN KEY ("targetOrganizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IntegrationCredential" ADD CONSTRAINT "IntegrationCredential_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IntegrationCredential" ADD CONSTRAINT "IntegrationCredential_workspace_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -80,10 +80,6 @@ model User {
OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[]
// Organization & Workspace relations
OrgMemberships OrgMember[]
TeamMemberships TeamMember[]
}
enum SubscriptionTier {
@@ -217,9 +213,6 @@ model BuilderSearchHistory {
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Tenancy
organizationId String?
}
////////////////////////////////////////////////////////////
@@ -253,14 +246,7 @@ model ChatSession {
Messages ChatMessage[]
// Tenancy
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
@@index([userId, updatedAt])
@@index([teamId, updatedAt])
}
model ChatMessage {
@@ -318,16 +304,9 @@ model AgentGraph {
LibraryAgents LibraryAgent[]
StoreListingVersions StoreListingVersion[]
// Tenancy columns (nullable during migration, NOT NULL after cutover)
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
@@id(name: "graphVersionId", [id, version])
@@index([userId, isActive, id, version])
@@index([forkedFromId, forkedFromVersion])
@@index([teamId, isActive, id, version])
}
////////////////////////////////////////////////////////////
@@ -368,16 +347,9 @@ model AgentPreset {
isDeleted Boolean @default(false)
// Tenancy
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([agentGraphId, agentGraphVersion])
@@index([webhookId])
@@index([teamId])
}
enum NotificationType {
@@ -421,10 +393,6 @@ model UserNotificationBatch {
Notifications NotificationEvent[]
// Tenancy
organizationId String?
teamId String?
// Each user can only have one batch of a notification type at a time
@@unique([userId, type])
}
@@ -460,17 +428,10 @@ model LibraryAgent {
settings Json @default("{}")
// Tenancy
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
@@unique([userId, agentGraphId, agentGraphVersion])
@@index([agentGraphId, agentGraphVersion])
@@index([creatorId])
@@index([folderId])
@@index([teamId])
}
model LibraryFolder {
@@ -493,12 +454,6 @@ model LibraryFolder {
LibraryAgents LibraryAgent[]
// Tenancy
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
@@unique([userId, parentId, name]) // Name unique per parent per user
}
@@ -632,19 +587,12 @@ model AgentGraphExecution {
shareToken String? @unique
sharedAt DateTime?
// Tenancy
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
@@index([agentGraphId, agentGraphVersion])
@@index([userId, isDeleted, createdAt])
@@index([createdAt])
@@index([agentPresetId])
@@index([shareToken])
@@index([parentGraphExecutionId])
@@index([teamId, isDeleted, createdAt])
}
// This model describes the execution of an AgentNode.
@@ -737,10 +685,6 @@ model PendingHumanReview {
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Tenancy
organizationId String?
teamId String?
@@unique([nodeExecId]) // One pending review per node execution
@@index([userId, status])
@@index([graphExecId, status])
@@ -767,12 +711,6 @@ model IntegrationWebhook {
AgentNodes AgentNode[]
AgentPresets AgentPreset[]
// Tenancy
organizationId String?
teamId String?
visibility ResourceVisibility @default(PRIVATE)
Team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull)
}
model AnalyticsDetails {
@@ -836,68 +774,6 @@ enum CreditTransactionType {
CARD_CHECK
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////// ORGANIZATION ORGANIZATION & WORKSPACE ENUMS TEAM ENUMS /////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
enum TeamJoinPolicy {
OPEN
PRIVATE
}
enum ResourceVisibility {
PRIVATE
TEAM
ORG
}
enum CredentialScope {
USER
TEAM
ORG
}
enum OrgAliasType {
MIGRATION
RENAME
MANUAL
}
enum OrgMemberStatus {
INVITED
ACTIVE
SUSPENDED
REMOVED
}
enum SeatType {
FREE
PAID
}
enum SeatStatus {
ACTIVE
INACTIVE
PENDING
}
enum TransferStatus {
PENDING
SOURCE_APPROVED
TARGET_APPROVED
COMPLETED
REJECTED
CANCELLED
}
enum CredentialOwnerType {
USER
TEAM
ORG
}
model CreditTransaction {
transactionKey String @default(uuid())
createdAt DateTime @default(now())
@@ -1137,7 +1013,7 @@ model StoreListing {
ActiveVersion StoreListingVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id])
// The agent link here is only so we can do lookup on agentId
agentGraphId String @unique
agentGraphId String @unique
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
@@ -1146,14 +1022,9 @@ model StoreListing {
// Relations
Versions StoreListingVersion[] @relation("ListingVersions")
// Tenancy — owningOrgId will replace owningUserId as canonical owner after cutover
owningOrgId String?
OwningOrg Organization? @relation("OrgStoreListings", fields: [owningOrgId], references: [id])
@@unique([owningUserId, slug])
// Used in the view query
@@index([isDeleted, hasApprovedVersion])
@@index([owningOrgId])
}
model StoreListingVersion {
@@ -1214,9 +1085,6 @@ model StoreListingVersion {
// Note: Embeddings now stored in UnifiedContentEmbedding table
// Use contentType=STORE_AGENT and contentId=storeListingVersionId
// Tenancy
organizationId String?
@@unique([storeListingId, version])
@@index([storeListingId, submissionStatus, isAvailable])
@@index([submissionStatus])
@@ -1325,16 +1193,8 @@ model APIKey {
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Tenancy
organizationId String?
teamId String?
ownerType CredentialOwnerType?
teamIdRestriction String?
Team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([head, name])
@@index([userId, status])
@@index([teamId])
}
model UserBalance {
@@ -1388,11 +1248,6 @@ model OAuthApplication {
AccessTokens OAuthAccessToken[]
RefreshTokens OAuthRefreshToken[]
// Tenancy
organizationId String?
ownerType CredentialOwnerType?
teamIdRestriction String?
@@index([clientId])
@@index([ownerId])
}
@@ -1467,321 +1322,3 @@ model OAuthRefreshToken {
@@index([userId, applicationId])
@@index([expiresAt]) // For cleanup
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////// ORGANIZATION ORGANIZATION & WORKSPACE MODELS TEAM MODELS //////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model Organization {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
slug String @unique
avatarUrl String?
description String?
isPersonal Boolean @default(false)
settings Json @default("{}")
stripeCustomerId String?
stripeSubscriptionId String?
topUpConfig Json?
archivedAt DateTime?
deletedAt DateTime?
bootstrapUserId String?
Members OrgMember[]
Teams Team[]
Aliases OrganizationAlias[]
Balance OrgBalance?
CreditTransactions OrgCreditTransaction[]
Invitations OrgInvitation[]
StoreListings StoreListing[] @relation("OrgStoreListings")
Credentials IntegrationCredential[]
Profile OrganizationProfile?
Subscription OrganizationSubscription?
SeatAssignments OrganizationSeatAssignment[]
TransfersFrom TransferRequest[] @relation("TransferSource")
TransfersTo TransferRequest[] @relation("TransferTarget")
AuditLogs AuditLog[]
// slug already has @unique which creates an index — no separate @@index needed
}
model OrganizationAlias {
id String @id @default(uuid())
organizationId String
aliasSlug String @unique
aliasType OrgAliasType
createdAt DateTime @default(now())
createdByUserId String?
removedAt DateTime?
removedByUserId String?
isRemovable Boolean @default(true)
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([aliasSlug])
@@index([organizationId])
}
model OrganizationProfile {
organizationId String @id
username String @unique
displayName String?
avatarUrl String?
bio String?
socialLinks Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
model OrgMember {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orgId String
userId String
isOwner Boolean @default(false)
isAdmin Boolean @default(false)
isBillingManager Boolean @default(false)
status OrgMemberStatus @default(ACTIVE)
joinedAt DateTime @default(now())
invitedByUserId String?
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
SeatAssignment OrganizationSeatAssignment? @relation
@@unique([orgId, userId])
@@index([userId])
@@index([orgId, status])
}
model OrgInvitation {
id String @id @default(uuid())
createdAt DateTime @default(now())
orgId String
email String
targetUserId String?
isAdmin Boolean @default(false)
isBillingManager Boolean @default(false)
token String @unique @default(uuid())
tokenHash String?
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
invitedByUserId String
teamIds String[]
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
@@index([email])
@@index([token])
@@index([orgId])
}
model Team {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
slug String?
description String?
isDefault Boolean @default(false)
joinPolicy TeamJoinPolicy @default(OPEN)
orgId String
archivedAt DateTime?
createdByUserId String?
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
Members TeamMember[]
AgentGraphs AgentGraph[]
Executions AgentGraphExecution[]
ChatSessions ChatSession[]
Presets AgentPreset[]
LibraryAgents LibraryAgent[]
LibraryFolders LibraryFolder[]
Webhooks IntegrationWebhook[]
APIKeys APIKey[]
Credentials IntegrationCredential[] @relation("TeamCredentials")
TeamInvites TeamInvite[]
@@unique([orgId, name])
@@index([orgId, isDefault])
@@index([orgId, joinPolicy])
}
model TeamMember {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teamId String
userId String
isAdmin Boolean @default(false)
isBillingManager Boolean @default(false)
status OrgMemberStatus @default(ACTIVE)
joinedAt DateTime @default(now())
invitedByUserId String?
Team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId])
@@index([userId])
@@index([teamId, status])
}
model TeamInvite {
id String @id @default(uuid())
createdAt DateTime @default(now())
teamId String
email String
targetUserId String?
isAdmin Boolean @default(false)
isBillingManager Boolean @default(false)
token String @unique @default(uuid())
tokenHash String?
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
invitedByUserId String
Team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([email])
@@index([token])
@@index([teamId])
}
model OrganizationSubscription {
organizationId String @id
planCode String?
planTier String?
stripeCustomerId String?
stripeSubscriptionId String?
status String @default("active")
renewalAt DateTime?
cancelAt DateTime?
entitlements Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
model OrganizationSeatAssignment {
id String @id @default(uuid())
organizationId String
userId String
seatType SeatType @default(FREE)
status SeatStatus @default(ACTIVE)
assignedByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
OrgMember OrgMember? @relation(fields: [organizationId, userId], references: [orgId, userId])
Organization Organization @relation(fields: [organizationId], references: [id])
@@unique([organizationId, userId])
@@index([organizationId, status])
}
model OrgBalance {
orgId String @id
balance Int @default(0)
updatedAt DateTime @updatedAt
Org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
}
model OrgCreditTransaction {
transactionKey String @default(uuid())
createdAt DateTime @default(now())
orgId String
initiatedByUserId String?
teamId String?
amount Int
type CreditTransactionType
runningBalance Int?
isActive Boolean @default(true)
metadata Json?
Org Organization @relation(fields: [orgId], references: [id], onDelete: NoAction)
@@id(name: "orgCreditTransactionIdentifier", [transactionKey, orgId])
@@index([orgId, createdAt])
@@index([initiatedByUserId])
}
model TransferRequest {
id String @id @default(uuid())
resourceType String
resourceId String
sourceOrganizationId String
targetOrganizationId String
initiatedByUserId String
status TransferStatus @default(PENDING)
sourceApprovedByUserId String?
targetApprovedByUserId String?
completedAt DateTime?
reason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
SourceOrg Organization @relation("TransferSource", fields: [sourceOrganizationId], references: [id])
TargetOrg Organization @relation("TransferTarget", fields: [targetOrganizationId], references: [id])
@@index([sourceOrganizationId])
@@index([targetOrganizationId])
@@index([status])
}
model AuditLog {
id String @id @default(uuid())
organizationId String?
teamId String?
actorUserId String
entityType String
entityId String?
action String
beforeJson Json?
afterJson Json?
correlationId String?
createdAt DateTime @default(now())
Organization Organization? @relation(fields: [organizationId], references: [id])
@@index([organizationId, createdAt])
@@index([actorUserId, createdAt])
@@index([entityType, entityId])
}
model IntegrationCredential {
id String @id @default(uuid())
organizationId String
ownerType CredentialOwnerType
ownerId String // userId, teamId, or orgId depending on ownerType
teamId String? // Dedicated FK for workspace-scoped creds (set when ownerType=TEAM)
provider String
credentialType String
displayName String
encryptedPayload String
createdByUserId String
lastUsedAt DateTime?
status String @default("active")
metadata Json?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
Workspace Team? @relation("TeamCredentials", fields: [teamId], references: [id], onDelete: Cascade, map: "IntegrationCredential_workspace_fkey")
@@index([organizationId, ownerType, provider])
@@index([ownerId, ownerType])
@@index([createdByUserId])
}

View File

@@ -112,17 +112,6 @@ class TestDataCreator:
self.api_keys: List[Dict[str, Any]] = []
self.presets: List[Dict[str, Any]] = []
self.profiles: List[Dict[str, Any]] = []
# Org/workspace context per user (populated after migration runs)
self._user_org_cache: Dict[str, tuple[str | None, str | None]] = {}
async def _get_user_org_ws(self, user_id: str) -> tuple[str | None, str | None]:
"""Get (organization_id, team_id) for a user, with caching."""
if user_id not in self._user_org_cache:
from backend.api.features.orgs.db import get_user_default_team
org_id, ws_id = await get_user_default_team(user_id)
self._user_org_cache[user_id] = (org_id, ws_id)
return self._user_org_cache[user_id]
async def create_test_users(self) -> List[Dict[str, Any]]:
"""Create test users using Supabase client."""
@@ -377,14 +366,8 @@ class TestDataCreator:
)
try:
# Use the API function to create graph with org context
org_id, ws_id = await self._get_user_org_ws(user["id"])
created_graph = await create_graph(
graph,
user["id"],
organization_id=org_id,
team_id=ws_id,
)
# Use the API function to create graph
created_graph = await create_graph(graph, user["id"])
graph_dict = created_graph.model_dump()
# Ensure userId is included for store submissions
graph_dict["userId"] = user["id"]

View File

@@ -69,20 +69,6 @@ export const customMutator = async <
error,
);
}
// Inject org/team context headers
try {
const activeOrgID = localStorage.getItem("active-org-id");
const activeTeamID = localStorage.getItem("active-team-id");
if (activeOrgID) {
headers["X-Org-Id"] = activeOrgID;
}
if (activeTeamID) {
headers["X-Team-Id"] = activeTeamID;
}
} catch (error) {
console.error("Org context: Failed to access localStorage:", error);
}
}
const isFormData = data instanceof FormData;

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { getQueryClient } from "@/lib/react-query/queryClient";
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
import OrgTeamProvider from "@/providers/org-team/OrgTeamProvider";
import {
PostHogPageViewTracker,
PostHogProvider,
@@ -31,15 +30,13 @@ export function Providers({ children, ...props }: ThemeProviderProps) {
<PostHogPageViewTracker />
</Suspense>
<CredentialsProvider>
<OrgTeamProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<ThemeProvider forcedTheme="light" {...props}>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</OrgTeamProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<ThemeProvider forcedTheme="light" {...props}>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</PostHogProvider>

View File

@@ -13,7 +13,6 @@ import { environment } from "@/services/environment";
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
import { FeedbackButton } from "./components/FeedbackButton";
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
import { OrgTeamSwitcher } from "./components/OrgTeamSwitcher/OrgTeamSwitcher";
import { LoginButton } from "./components/LoginButton";
import { MobileNavBar } from "./components/MobileNavbar/MobileNavBar";
import { NavbarLink } from "./components/NavbarLink";
@@ -94,7 +93,6 @@ export function Navbar() {
{isLoggedIn && !isSmallScreen ? (
<div className="flex flex-1 items-center justify-end gap-4">
<div className="flex items-center gap-4">
<OrgTeamSwitcher />
<FeedbackButton />
<AgentActivityDropdown />
{profile && <Wallet key={profile.username} />}

View File

@@ -1,131 +0,0 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { useOrgTeamSwitcher } from "./useOrgTeamSwitcher";
import { CaretDown, Check, Plus, GearSix } from "@phosphor-icons/react";
import Link from "next/link";
export function OrgTeamSwitcher() {
const {
orgs,
teams,
activeOrg,
activeTeam,
switchOrg,
switchTeam,
isLoaded,
} = useOrgTeamSwitcher();
if (!isLoaded || orgs.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/60 px-2.5 py-1.5 text-sm font-medium text-neutral-700 hover:bg-white/80"
aria-label="Switch organization"
data-testid="org-switcher-trigger"
>
<Avatar className="h-5 w-5">
<AvatarImage
src={activeOrg?.avatarUrl ?? ""}
alt=""
aria-hidden="true"
/>
<AvatarFallback className="text-xs" aria-hidden="true">
{activeOrg?.name?.charAt(0) || "O"}
</AvatarFallback>
</Avatar>
<span className="max-w-[8rem] truncate">{activeOrg?.name}</span>
<CaretDown size={12} />
</button>
</PopoverTrigger>
<PopoverContent
className="flex w-64 flex-col gap-2 rounded-xl bg-white p-2 shadow-lg"
align="end"
data-testid="org-switcher-popover"
>
{/* Org list */}
<div className="flex flex-col gap-0.5">
<span className="px-2 py-1 text-xs font-medium uppercase text-neutral-400">
Organizations
</span>
{orgs.map((org) => (
<button
key={org.id}
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-neutral-100"
onClick={() => switchOrg(org.id)}
>
<Avatar className="h-5 w-5">
<AvatarImage src={org.avatarUrl ?? ""} alt="" />
<AvatarFallback className="text-xs">
{org.name.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate text-left">{org.name}</span>
{org.isPersonal && (
<span className="text-xs text-neutral-400">Personal</span>
)}
{org.id === activeOrg?.id && (
<Check size={14} className="text-green-600" />
)}
</button>
))}
<Link
href="/org/settings"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-neutral-500 hover:bg-neutral-100"
>
<Plus size={14} />
<span>Create organization</span>
</Link>
</div>
{/* Team list (only if orgs exist) */}
{teams.length > 0 && (
<>
<div className="border-t border-neutral-100" />
<div className="flex flex-col gap-0.5">
<span className="px-2 py-1 text-xs font-medium uppercase text-neutral-400">
Teams
</span>
{teams.map((ws) => (
<button
key={ws.id}
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-neutral-100"
onClick={() => switchTeam(ws.id)}
>
<span className="flex-1 truncate text-left">{ws.name}</span>
{ws.joinPolicy === "PRIVATE" && (
<span className="text-xs text-neutral-400">Private</span>
)}
{ws.id === activeTeam?.id && (
<Check size={14} className="text-green-600" />
)}
</button>
))}
<Link
href="/org/teams"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-neutral-500 hover:bg-neutral-100"
>
<GearSix size={14} />
<span>Manage teams</span>
</Link>
</div>
</>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -1,43 +0,0 @@
import { useOrgTeamStore } from "@/services/org-team/store";
import { getQueryClient } from "@/lib/react-query/queryClient";
export function useOrgTeamSwitcher() {
const {
orgs,
teams,
activeOrgID,
activeTeamID,
setActiveOrg,
setActiveTeam,
isLoaded,
} = useOrgTeamStore();
const activeOrg = orgs.find((o) => o.id === activeOrgID) || null;
const activeTeam = teams.find((w) => w.id === activeTeamID) || null;
function switchOrg(orgID: string) {
if (orgID === activeOrgID) return;
setActiveOrg(orgID);
// Clear cache to force refetch with new org context
const queryClient = getQueryClient();
queryClient.clear();
}
function switchTeam(teamID: string) {
if (teamID === activeTeamID) return;
setActiveTeam(teamID);
// Clear cache for team-scoped data
const queryClient = getQueryClient();
queryClient.clear();
}
return {
orgs,
teams,
activeOrg,
activeTeam,
switchOrg,
switchTeam,
isLoaded,
};
}

View File

@@ -1,80 +0,0 @@
"use client";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useOrgTeamStore } from "@/services/org-team/store";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useEffect, useRef } from "react";
interface Props {
children: React.ReactNode;
}
/**
* Initializes org/team context on login and clears it on logout.
*
* On mount (when logged in):
* 1. Fetches the user's org list from GET /api/orgs
* 2. If no activeOrgID is stored, sets the personal org as default
* 3. Fetches teams for the active org
*
* On org/team switch: clears React Query cache to force refetch.
*/
export default function OrgTeamProvider({ children }: Props) {
const { isLoggedIn, user } = useSupabase();
const { activeOrgID, setActiveOrg, setOrgs, setLoaded, clearContext } =
useOrgTeamStore();
const prevOrgID = useRef(activeOrgID);
// Fetch orgs when logged in
useEffect(() => {
if (!isLoggedIn || !user) {
clearContext();
return;
}
async function loadOrgs() {
try {
const res = await fetch("/api/proxy/api/orgs", {
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
setLoaded(true);
return;
}
const data = await res.json();
const orgs = data.data || data;
setOrgs(orgs);
// If no active org, set the personal org as default
if (!activeOrgID && orgs.length > 0) {
const personal = orgs.find(
(o: { isPersonal: boolean }) => o.isPersonal,
);
if (personal) {
setActiveOrg(personal.id);
} else {
setActiveOrg(orgs[0].id);
}
}
setLoaded(true);
} catch {
setLoaded(true);
}
}
loadOrgs();
}, [isLoggedIn, user]);
// Clear React Query cache when org switches
useEffect(() => {
if (prevOrgID.current !== activeOrgID && prevOrgID.current !== null) {
const queryClient = getQueryClient();
queryClient.clear();
}
prevOrgID.current = activeOrgID;
}, [activeOrgID]);
return <>{children}</>;
}

View File

@@ -1,83 +0,0 @@
import { Key, storage } from "@/services/storage/local-storage";
import { create } from "zustand";
interface Org {
id: string;
name: string;
slug: string;
avatarUrl: string | null;
isPersonal: boolean;
memberCount: number;
}
interface Team {
id: string;
name: string;
slug: string | null;
isDefault: boolean;
joinPolicy: string;
orgId: string;
}
interface OrgTeamState {
activeOrgID: string | null;
activeTeamID: string | null;
orgs: Org[];
teams: Team[];
isLoaded: boolean;
setActiveOrg(orgID: string): void;
setActiveTeam(teamID: string | null): void;
setOrgs(orgs: Org[]): void;
setTeams(teams: Team[]): void;
setLoaded(loaded: boolean): void;
clearContext(): void;
}
export const useOrgTeamStore = create<OrgTeamState>((set) => ({
activeOrgID: storage.get(Key.ACTIVE_ORG) || null,
activeTeamID: storage.get(Key.ACTIVE_TEAM) || null,
orgs: [],
teams: [],
isLoaded: false,
setActiveOrg(orgID: string) {
storage.set(Key.ACTIVE_ORG, orgID);
set({ activeOrgID: orgID, activeTeamID: null });
// Clear team when switching org — provider will resolve default
storage.clean(Key.ACTIVE_TEAM);
},
setActiveTeam(teamID: string | null) {
if (teamID) {
storage.set(Key.ACTIVE_TEAM, teamID);
} else {
storage.clean(Key.ACTIVE_TEAM);
}
set({ activeTeamID: teamID });
},
setOrgs(orgs: Org[]) {
set({ orgs });
},
setTeams(teams: Team[]) {
set({ teams });
},
setLoaded(loaded: boolean) {
set({ isLoaded: loaded });
},
clearContext() {
storage.clean(Key.ACTIVE_ORG);
storage.clean(Key.ACTIVE_TEAM);
set({
activeOrgID: null,
activeTeamID: null,
orgs: [],
teams: [],
isLoaded: false,
});
},
}));

View File

@@ -15,8 +15,6 @@ export enum Key {
COPILOT_NOTIFICATIONS_ENABLED = "copilot-notifications-enabled",
COPILOT_NOTIFICATION_BANNER_DISMISSED = "copilot-notification-banner-dismissed",
COPILOT_NOTIFICATION_DIALOG_DISMISSED = "copilot-notification-dialog-dismissed",
ACTIVE_ORG = "active-org-id",
ACTIVE_TEAM = "active-team-id",
COPILOT_ARTIFACT_PANEL_WIDTH = "copilot-artifact-panel-width",
COPILOT_MODE = "copilot-mode",
COPILOT_COMPLETED_SESSIONS = "copilot-completed-sessions",