From 06cb44aff41551f00c24cbc6d0933e3b3a9635db Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:32:09 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20MVP1=20RBAC=20foundation=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/auth/permissions.py | 134 ++++++++ apps/gateway/services/auth_service.py | 6 + apps/gateway/services/organization_context.py | 44 +++ .../tests/api/test_permission_helpers.py | 61 ++++ ...1f2a3b4c5d6_add_user_direct_permissions.py | 102 ++++++ apps/shared/audit/actions.py | 4 + apps/shared/audit/listeners.py | 6 + apps/shared/db/models/__init__.py | 6 + apps/shared/db/models/team.py | 131 ++++++++ apps/shared/permissions.py | 115 +++++++ apps/shared/services/permissions.py | 280 ++++++++++++++++ apps/shared/services/tracing/access.py | 6 +- apps/shared/services/tracing/rbac.py | 73 ++--- .../tests/audit/test_audit_listeners.py | 4 + .../shared/tests/services/test_permissions.py | 299 ++++++++++++++++++ .../tests/services/test_tracing_access.py | 61 +++- tests/db/test_organization_user_schema.py | 9 +- tests/db/test_team_permission_constraints.py | 36 ++- tests/services/test_auth_service.py | 61 +++- 19 files changed, 1349 insertions(+), 89 deletions(-) create mode 100644 apps/gateway/auth/permissions.py create mode 100644 apps/gateway/tests/api/test_permission_helpers.py create mode 100644 apps/shared/alembic/versions/e1f2a3b4c5d6_add_user_direct_permissions.py create mode 100644 apps/shared/permissions.py create mode 100644 apps/shared/services/permissions.py create mode 100644 apps/shared/tests/services/test_permissions.py diff --git a/apps/gateway/auth/permissions.py b/apps/gateway/auth/permissions.py new file mode 100644 index 000000000..a6cc3750b --- /dev/null +++ b/apps/gateway/auth/permissions.py @@ -0,0 +1,134 @@ +from typing import Any + +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session + +from apps.gateway.auth.dependencies import get_current_user +from apps.shared.audit.actions import AuditAction +from apps.shared.audit.logger import record_audit +from apps.shared.db.models.llm import LLMCredential +from apps.shared.db.models.user import User +from apps.shared.db.models.workflow import Workflow +from apps.shared.db.session import get_db +from apps.shared.services.permissions import ( + get_effective_llm_credential_auth_state, + get_effective_workflow_auth_state, + has_llm_credential_permission, + has_workflow_permission, +) + + +def record_permission_denied( + user: User, + resource_type: str, + resource_id: Any, + action: str, + effective_auth_state: str, +) -> None: + record_audit( + action=AuditAction.PERMISSION_DENIED, + category="action", + actor_id=user.id, + actor_type="user", + target_type=resource_type, + target_id=resource_id, + status="failure", + metadata={ + "permission_action": action, + "effective_auth_state": effective_auth_state, + }, + ) + + +def ensure_workflow_permission( + db: Session, + current_user: User, + workflow_id: Any, + action: str, +) -> Workflow: + workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + effective_auth_state = get_effective_workflow_auth_state( + db, + current_user.id, + workflow.id, + organization_id=workflow.organization_id, + ) + if not has_workflow_permission( + db, + current_user.id, + workflow.id, + action, + organization_id=workflow.organization_id, + ): + record_permission_denied( + current_user, + "workflow", + workflow.id, + action, + effective_auth_state, + ) + raise HTTPException(status_code=403, detail="Forbidden") + return workflow + + +def ensure_llm_credential_permission( + db: Session, + current_user: User, + credential_id: Any, + action: str, +) -> LLMCredential: + credential = ( + db.query(LLMCredential).filter(LLMCredential.id == credential_id).first() + ) + if not credential: + raise HTTPException(status_code=404, detail="Credential not found") + + effective_auth_state = get_effective_llm_credential_auth_state( + db, + current_user.id, + credential.id, + organization_id=credential.organization_id, + ) + if not has_llm_credential_permission( + db, + current_user.id, + credential.id, + action, + organization_id=credential.organization_id, + ): + record_permission_denied( + current_user, + "llm_credential", + credential.id, + action, + effective_auth_state, + ) + raise HTTPException(status_code=403, detail="Forbidden") + return credential + + +def require_workflow_permission(action: str): + def dependency( + workflow_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + ) -> Workflow: + return ensure_workflow_permission(db, current_user, workflow_id, action) + + return dependency + + +def require_llm_credential_permission(action: str): + def dependency( + credential_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + ) -> LLMCredential: + return ensure_llm_credential_permission( + db, current_user, credential_id, action + ) + + return dependency diff --git a/apps/gateway/services/auth_service.py b/apps/gateway/services/auth_service.py index 64e7f06d6..d999f45c4 100644 --- a/apps/gateway/services/auth_service.py +++ b/apps/gateway/services/auth_service.py @@ -1,12 +1,14 @@ import hashlib import os import secrets +import uuid from datetime import datetime, timedelta, timezone from fastapi import HTTPException from jose import JWTError, jwt from sqlalchemy.orm import Session +from apps.gateway.services.organization_context import ensure_user_default_organization from apps.shared.db.models.user import User from apps.shared.schemas.auth import ( LoginRequest, @@ -55,6 +57,7 @@ def get_or_create_social_user( return user new_user = User( + id=uuid.uuid4(), email=email, name=name, social_provider=social_provider, @@ -62,6 +65,7 @@ def get_or_create_social_user( avatar_url=avatar_url, ) db.add(new_user) + ensure_user_default_organization(db, new_user) db.commit() db.refresh(new_user) return new_user @@ -142,6 +146,7 @@ def signup(db: Session, request: SignupRequest) -> LoginResponse: hashed_pwd = AuthService.hash_password(request.password) new_user = User( + id=uuid.uuid4(), email=request.email, name=request.name, password=hashed_pwd, @@ -149,6 +154,7 @@ def signup(db: Session, request: SignupRequest) -> LoginResponse: last_login_at=datetime.now(timezone.utc), ) db.add(new_user) + ensure_user_default_organization(db, new_user) db.commit() db.refresh(new_user) diff --git a/apps/gateway/services/organization_context.py b/apps/gateway/services/organization_context.py index 129e522ba..a9d85297a 100644 --- a/apps/gateway/services/organization_context.py +++ b/apps/gateway/services/organization_context.py @@ -1,7 +1,9 @@ import uuid from typing import Optional +from apps.shared.db.models.organization import Organization from apps.shared.db.models.team import Team, TeamMembership +from apps.shared.db.models.user import User from sqlalchemy.orm import Session @@ -23,3 +25,45 @@ def get_user_primary_organization_id( .first() ) return row[0] if row else None + + +def ensure_user_default_organization( + db: Session, + user: User | uuid.UUID, +) -> uuid.UUID: + """Create the default organization/team/membership foundation if missing.""" + + user_id = user.id if isinstance(user, User) else user + user_name = getattr(user, "name", None) + existing_id = get_user_primary_organization_id(db, user_id) + if existing_id: + return existing_id + + if not user_name: + db_user = db.query(User).filter(User.id == user_id).first() + user_name = db_user.name if db_user else "Personal" + + organization = Organization( + id=uuid.uuid4(), + name=f"{user_name}'s Organization", + created_by=user_id, + managed_by=user_id, + ) + team = Team( + id=uuid.uuid4(), + organization_id=organization.id, + name="Default", + created_by=user_id, + managed_by=user_id, + is_auto_add=True, + ) + membership = TeamMembership( + grantee_organization_id=organization.id, + user_id=user_id, + team_id=team.id, + assigned_by=user_id, + ) + db.add(organization) + db.add(team) + db.add(membership) + return organization.id diff --git a/apps/gateway/tests/api/test_permission_helpers.py b/apps/gateway/tests/api/test_permission_helpers.py new file mode 100644 index 000000000..fbfe0ed76 --- /dev/null +++ b/apps/gateway/tests/api/test_permission_helpers.py @@ -0,0 +1,61 @@ +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from apps.gateway.auth import permissions +from apps.gateway.auth.permissions import ensure_workflow_permission +from apps.shared.audit.actions import AuditAction + + +class FakeQuery: + def __init__(self, workflow): + self.workflow = workflow + + def filter(self, *args, **kwargs): + return self + + def first(self): + return self.workflow + + +class FakeDb: + def __init__(self, workflow): + self.workflow = workflow + + def query(self, *args, **kwargs): + return FakeQuery(self.workflow) + + +def test_workflow_permission_denied_records_permission_audit(monkeypatch): + workflow = SimpleNamespace(id=uuid.uuid4(), organization_id=uuid.uuid4()) + user = SimpleNamespace(id=uuid.uuid4()) + events = [] + + monkeypatch.setattr( + permissions, + "get_effective_workflow_auth_state", + lambda db, user_id, workflow_id, organization_id=None: "viewer", + ) + monkeypatch.setattr( + permissions, + "has_workflow_permission", + lambda db, user_id, workflow_id, action, organization_id=None: False, + ) + monkeypatch.setattr( + permissions, + "record_audit", + lambda **event: events.append(event), + ) + + with pytest.raises(HTTPException) as exc_info: + ensure_workflow_permission(FakeDb(workflow), user, workflow.id, "write") + + assert exc_info.value.status_code == 403 + assert events[0]["action"] == AuditAction.PERMISSION_DENIED + assert events[0]["target_type"] == "workflow" + assert events[0]["target_id"] == workflow.id + assert events[0]["status"] == "failure" + assert events[0]["metadata"]["permission_action"] == "write" + assert events[0]["metadata"]["effective_auth_state"] == "viewer" diff --git a/apps/shared/alembic/versions/e1f2a3b4c5d6_add_user_direct_permissions.py b/apps/shared/alembic/versions/e1f2a3b4c5d6_add_user_direct_permissions.py new file mode 100644 index 000000000..a81825146 --- /dev/null +++ b/apps/shared/alembic/versions/e1f2a3b4c5d6_add_user_direct_permissions.py @@ -0,0 +1,102 @@ +"""Add user direct resource permissions + +Revision ID: e1f2a3b4c5d6 +Revises: c2d3e4f5a6b7 +Create Date: 2026-06-27 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "e1f2a3b4c5d6" +down_revision: Union[str, Sequence[str], None] = "c2d3e4f5a6b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _create_direct_permission_table( + table_name: str, + resource_column: str, + resource_table: str, + unique_name: str, + check_name: str, +) -> None: + op.create_table( + table_name, + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("grantee_organization_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column(resource_column, sa.UUID(), nullable=False), + sa.Column( + "auth_state", + sa.String(length=50), + nullable=False, + server_default="none", + ), + sa.Column("assigned_by", sa.UUID(), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "options", + postgresql.JSONB(), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("flags", sa.BigInteger(), nullable=False, server_default=sa.text("0")), + sa.CheckConstraint("flags >= 0", name=check_name), + sa.ForeignKeyConstraint(["assigned_by"], ["users.id"]), + sa.ForeignKeyConstraint(["grantee_organization_id"], ["organization.id"]), + sa.ForeignKeyConstraint([resource_column], [f"{resource_table}.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "grantee_organization_id", + "user_id", + resource_column, + name=unique_name, + ), + ) + op.create_index(f"ix_{table_name}_assigned_by", table_name, ["assigned_by"]) + op.create_index( + f"ix_{table_name}_grantee_organization_id", + table_name, + ["grantee_organization_id"], + ) + op.create_index(f"ix_{table_name}_{resource_column}", table_name, [resource_column]) + op.create_index(f"ix_{table_name}_user_id", table_name, ["user_id"]) + op.alter_column(table_name, "auth_state", server_default=None) + + +def upgrade() -> None: + _create_direct_permission_table( + "user_workflow_permissions", + "workflow_id", + "workflows", + "uq_user_workflow_permissions_org_user_workflow", + "ck_user_workflow_permissions_flags_nonnegative", + ) + _create_direct_permission_table( + "user_llm_permissions", + "llm_credential_id", + "llm_credentials", + "uq_user_llm_permissions_org_user_credential", + "ck_user_llm_permissions_flags_nonnegative", + ) + + +def downgrade() -> None: + for table_name, resource_column in ( + ("user_llm_permissions", "llm_credential_id"), + ("user_workflow_permissions", "workflow_id"), + ): + op.drop_index(f"ix_{table_name}_user_id", table_name=table_name) + op.drop_index(f"ix_{table_name}_{resource_column}", table_name=table_name) + op.drop_index( + f"ix_{table_name}_grantee_organization_id", table_name=table_name + ) + op.drop_index(f"ix_{table_name}_assigned_by", table_name=table_name) + op.drop_table(table_name) diff --git a/apps/shared/audit/actions.py b/apps/shared/audit/actions.py index a0a739e09..fa9e2179e 100644 --- a/apps/shared/audit/actions.py +++ b/apps/shared/audit/actions.py @@ -14,6 +14,10 @@ class AuditAction: USER_LOGOUT = "user.logout" AUTH_PERMISSION_DENIED = "auth.permission_denied" + PERMISSION_GRANT = "permission.grant" + PERMISSION_REVOKE = "permission.revoke" + PERMISSION_DENIED = "permission.denied" + # 앱/워크플로우/배포: 사용자가 워크플로우 운영 단위에서 수행한 행동. APP_CREATE = "app.create" APP_UPDATE = "app.update" diff --git a/apps/shared/audit/listeners.py b/apps/shared/audit/listeners.py index ec8a52d26..e328fda59 100644 --- a/apps/shared/audit/listeners.py +++ b/apps/shared/audit/listeners.py @@ -31,6 +31,8 @@ TeamLLMPermission, TeamMembership, TeamWorkflowPermission, + UserLLMPermission, + UserWorkflowPermission, ) from apps.shared.db.models.user import User from apps.shared.db.models.workflow import Workflow @@ -59,6 +61,8 @@ TeamKnowledgePermission: "team_knowledge_permission", TeamLLMPermission: "team_llm_permission", TeamAuditPermission: "team_audit_permission", + UserWorkflowPermission: "user_workflow_permission", + UserLLMPermission: "user_llm_permission", TraceRedactionPolicy: "trace_redaction_policy", TraceRetentionPolicy: "trace_retention_policy", TraceVisibilityPolicy: "trace_visibility_policy", @@ -94,6 +98,8 @@ TeamKnowledgePermission: set(), TeamLLMPermission: set(), TeamAuditPermission: set(), + UserWorkflowPermission: set(), + UserLLMPermission: set(), TraceRedactionPolicy: {"regex_rules"}, TraceRetentionPolicy: set(), TraceVisibilityPolicy: set(), diff --git a/apps/shared/db/models/__init__.py b/apps/shared/db/models/__init__.py index 20ef4aee2..fc1d68db7 100644 --- a/apps/shared/db/models/__init__.py +++ b/apps/shared/db/models/__init__.py @@ -28,6 +28,9 @@ TeamResourcePermissionMixin, TeamMembership, TeamWorkflowPermission, + UserLLMPermission, + UserResourcePermissionMixin, + UserWorkflowPermission, ) from apps.shared.db.models.organization import Organization from apps.shared.db.models.user import User @@ -61,11 +64,14 @@ "Team", "TeamAssignmentMixin", "TeamResourcePermissionMixin", + "UserResourcePermissionMixin", "TeamMembership", "TeamKnowledgePermission", "TeamLLMPermission", "TeamAuditPermission", "TeamWorkflowPermission", + "UserWorkflowPermission", + "UserLLMPermission", "Workflow", "WorkflowDeployment", "WorkflowNodeRun", diff --git a/apps/shared/db/models/team.py b/apps/shared/db/models/team.py index 334ca31ec..3f039c116 100644 --- a/apps/shared/db/models/team.py +++ b/apps/shared/db/models/team.py @@ -165,6 +165,73 @@ def auth_state(cls) -> Mapped[str]: return mapped_column(String(50), nullable=False, default="none") +class UserResourcePermissionMixin: + """Common assignment columns for direct user resource permissions.""" + + @declared_attr + def grantee_organization_id(cls) -> Mapped[uuid.UUID]: + return mapped_column( + UUID(as_uuid=True), + ForeignKey("organization.id"), + nullable=False, + index=True, + ) + + @declared_attr + def user_id(cls) -> Mapped[uuid.UUID]: + return mapped_column( + UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True + ) + + @declared_attr + def auth_state(cls) -> Mapped[str]: + return mapped_column(String(50), nullable=False, default="none") + + @declared_attr + def assigned_by(cls) -> Mapped[uuid.UUID]: + return mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + index=True, + ) + + @declared_attr + def assigned_at(cls) -> Mapped[datetime]: + return mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + @declared_attr + def options(cls) -> Mapped[dict]: + return mapped_column( + JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb") + ) + + @declared_attr + def flags(cls) -> Mapped[int]: + return mapped_column( + BigInteger, nullable=False, default=0, server_default=text("0") + ) + + @declared_attr + def grantee_organization(cls) -> Mapped["Organization"]: + return relationship( + "Organization", + foreign_keys=lambda: [cls.grantee_organization_id], + ) + + @declared_attr + def user(cls) -> Mapped["User"]: + return relationship("User", foreign_keys=lambda: [cls.user_id]) + + @declared_attr + def assigner(cls) -> Mapped["User"]: + return relationship("User", foreign_keys=lambda: [cls.assigned_by]) + + class TeamMembership(TeamAssignmentMixin, Base): """Membership: which user belongs to which team.""" @@ -236,6 +303,38 @@ class TeamWorkflowPermission(TeamResourcePermissionMixin, Base): workflow: Mapped["Workflow"] = relationship("Workflow") +class UserWorkflowPermission(UserResourcePermissionMixin, Base): + """Direct additive user permission for a workflow resource.""" + + __tablename__ = "user_workflow_permissions" + __table_args__ = ( + UniqueConstraint( + "grantee_organization_id", + "user_id", + "workflow_id", + name="uq_user_workflow_permissions_org_user_workflow", + ), + CheckConstraint( + "flags >= 0", name="ck_user_workflow_permissions_flags_nonnegative" + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + nullable=False, + ) + workflow_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workflows.id"), + nullable=False, + index=True, + ) + + workflow: Mapped["Workflow"] = relationship("Workflow") + + class TeamKnowledgePermission(TeamResourcePermissionMixin, Base): """Team permission for a knowledge base resource.""" @@ -310,6 +409,38 @@ class TeamLLMPermission(TeamResourcePermissionMixin, Base): llm_credential: Mapped["LLMCredential"] = relationship("LLMCredential") +class UserLLMPermission(UserResourcePermissionMixin, Base): + """Direct additive user permission for an LLM credential resource.""" + + __tablename__ = "user_llm_permissions" + __table_args__ = ( + UniqueConstraint( + "grantee_organization_id", + "user_id", + "llm_credential_id", + name="uq_user_llm_permissions_org_user_credential", + ), + CheckConstraint( + "flags >= 0", name="ck_user_llm_permissions_flags_nonnegative" + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + nullable=False, + ) + llm_credential_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("llm_credentials.id"), + nullable=False, + index=True, + ) + + llm_credential: Mapped["LLMCredential"] = relationship("LLMCredential") + + class TeamAuditPermission(TeamResourcePermissionMixin, Base): """Team permission for audit visibility over one target organization.""" diff --git a/apps/shared/permissions.py b/apps/shared/permissions.py new file mode 100644 index 000000000..79e9cb832 --- /dev/null +++ b/apps/shared/permissions.py @@ -0,0 +1,115 @@ +from typing import Any + + +AUTH_STATE_NONE = "none" +AUTH_STATE_VIEWER = "viewer" +AUTH_STATE_OPERATOR = "operator" +AUTH_STATE_BUILDER = "builder" +AUTH_STATE_MANAGER = "manager" +AUTH_STATE_AUDITOR = "auditor" +AUTH_STATE_RAW_AUDITOR = "raw_auditor" + +CANONICAL_AUTH_STATES = { + AUTH_STATE_NONE, + AUTH_STATE_VIEWER, + AUTH_STATE_OPERATOR, + AUTH_STATE_BUILDER, + AUTH_STATE_MANAGER, + AUTH_STATE_AUDITOR, + AUTH_STATE_RAW_AUDITOR, +} + +LEGACY_AUTH_STATE_MAP = { + "read": AUTH_STATE_VIEWER, + "write": AUTH_STATE_BUILDER, + "execute": AUTH_STATE_OPERATOR, + "admin": AUTH_STATE_MANAGER, +} + +AUTH_STATE_RANK = { + AUTH_STATE_NONE: 0, + AUTH_STATE_VIEWER: 1, + AUTH_STATE_AUDITOR: 1, + AUTH_STATE_OPERATOR: 2, + AUTH_STATE_RAW_AUDITOR: 2, + AUTH_STATE_BUILDER: 3, + AUTH_STATE_MANAGER: 4, +} + +RESOURCE_AUTH_STATES = { + AUTH_STATE_NONE, + AUTH_STATE_VIEWER, + AUTH_STATE_OPERATOR, + AUTH_STATE_BUILDER, + AUTH_STATE_MANAGER, +} + +WORKFLOW_ACTION_MINIMUM_AUTH_STATE = { + "read": AUTH_STATE_VIEWER, + "execute": AUTH_STATE_OPERATOR, + "write": AUTH_STATE_BUILDER, + "deploy": AUTH_STATE_MANAGER, + "manage": AUTH_STATE_MANAGER, +} + +LLM_CREDENTIAL_ACTION_MINIMUM_AUTH_STATE = { + "read": AUTH_STATE_VIEWER, + "use": AUTH_STATE_OPERATOR, + "write": AUTH_STATE_MANAGER, + "manage": AUTH_STATE_MANAGER, +} + + +def normalize_auth_state(auth_state: Any) -> str: + value = str(auth_state or AUTH_STATE_NONE).lower() + value = LEGACY_AUTH_STATE_MAP.get(value, value) + if value not in AUTH_STATE_RANK: + return AUTH_STATE_NONE + return value + + +def is_canonical_auth_state(auth_state: Any) -> bool: + return str(auth_state or "").lower() in CANONICAL_AUTH_STATES + + +def stronger_auth_state(left: Any, right: Any) -> str: + normalized_left = normalize_auth_state(left) + normalized_right = normalize_auth_state(right) + if AUTH_STATE_RANK[normalized_right] > AUTH_STATE_RANK[normalized_left]: + return normalized_right + return normalized_left + + +def normalize_resource_auth_state(auth_state: Any) -> str: + value = normalize_auth_state(auth_state) + if value not in RESOURCE_AUTH_STATES: + return AUTH_STATE_NONE + return value + + +def stronger_resource_auth_state(left: Any, right: Any) -> str: + normalized_left = normalize_resource_auth_state(left) + normalized_right = normalize_resource_auth_state(right) + if AUTH_STATE_RANK[normalized_right] > AUTH_STATE_RANK[normalized_left]: + return normalized_right + return normalized_left + + +def auth_state_at_least(auth_state: Any, minimum: Any) -> bool: + normalized_state = normalize_auth_state(auth_state) + normalized_minimum = normalize_auth_state(minimum) + return AUTH_STATE_RANK[normalized_state] >= AUTH_STATE_RANK[normalized_minimum] + + +def workflow_auth_state_allows(auth_state: Any, action: str) -> bool: + minimum = WORKFLOW_ACTION_MINIMUM_AUTH_STATE.get(action) + if minimum is None: + return False + return auth_state_at_least(normalize_resource_auth_state(auth_state), minimum) + + +def llm_credential_auth_state_allows(auth_state: Any, action: str) -> bool: + minimum = LLM_CREDENTIAL_ACTION_MINIMUM_AUTH_STATE.get(action) + if minimum is None: + return False + return auth_state_at_least(normalize_resource_auth_state(auth_state), minimum) diff --git a/apps/shared/services/permissions.py b/apps/shared/services/permissions.py new file mode 100644 index 000000000..f9294988b --- /dev/null +++ b/apps/shared/services/permissions.py @@ -0,0 +1,280 @@ +import uuid +from typing import Any, Optional + +from sqlalchemy.orm import Session + +from apps.shared.db.models.llm import LLMCredential +from apps.shared.db.models.organization import Organization +from apps.shared.db.models.team import ( + Team, + TeamLLMPermission, + TeamMembership, + TeamWorkflowPermission, + UserLLMPermission, + UserWorkflowPermission, +) +from apps.shared.db.models.workflow import Workflow +from apps.shared.permissions import ( + AUTH_STATE_MANAGER, + AUTH_STATE_NONE, + llm_credential_auth_state_allows, + normalize_resource_auth_state, + stronger_resource_auth_state, + workflow_auth_state_allows, +) + + +def coerce_uuid(value: Any) -> Optional[uuid.UUID]: + if value is None or isinstance(value, uuid.UUID): + return value + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError): + return None + + +def _same_uuid(left: Any, right: Any) -> bool: + left_uuid = coerce_uuid(left) + right_uuid = coerce_uuid(right) + return left_uuid is not None and left_uuid == right_uuid + + +def _organization_manager_state( + db: Session, + user_id: uuid.UUID, + organization_id: uuid.UUID, +) -> Optional[str]: + organization = ( + db.query(Organization).filter(Organization.id == organization_id).first() + ) + return _manager_state_for_organization(organization, user_id) + + +def _manager_state_for_organization( + organization: Optional[Organization], + user_id: uuid.UUID, +) -> Optional[str]: + if not organization or not organization.is_active: + return None + if _same_uuid(organization.created_by, user_id) or _same_uuid( + organization.managed_by, user_id + ): + return AUTH_STATE_MANAGER + return None + + +def has_organization_manager_permission( + db: Session, + user_id: Any, + organization_id: Any, +) -> bool: + user_uuid = coerce_uuid(user_id) + organization_uuid = coerce_uuid(organization_id) + if user_uuid is None or organization_uuid is None: + return False + return ( + _organization_manager_state(db, user_uuid, organization_uuid) + == AUTH_STATE_MANAGER + ) + + +def _workflow_scope( + db: Session, + workflow_id: Any, + organization_id: Any = None, +) -> tuple[Optional[Workflow], Optional[uuid.UUID]]: + workflow_uuid = coerce_uuid(workflow_id) + requested_organization_uuid = coerce_uuid(organization_id) + if workflow_uuid is None: + return None, None + + workflow = db.query(Workflow).filter(Workflow.id == workflow_uuid).first() + if not workflow: + return None, None + + workflow_organization_uuid = coerce_uuid(workflow.organization_id) + if ( + workflow_organization_uuid is not None + and requested_organization_uuid is not None + and workflow_organization_uuid != requested_organization_uuid + ): + return workflow, None + + return workflow, workflow_organization_uuid or requested_organization_uuid + + +def _llm_credential_scope( + db: Session, + llm_credential_id: Any, + organization_id: Any = None, +) -> tuple[Optional[LLMCredential], Optional[uuid.UUID]]: + credential_uuid = coerce_uuid(llm_credential_id) + requested_organization_uuid = coerce_uuid(organization_id) + if credential_uuid is None: + return None, None + + credential = ( + db.query(LLMCredential).filter(LLMCredential.id == credential_uuid).first() + ) + if not credential: + return None, None + + credential_organization_uuid = coerce_uuid(credential.organization_id) + if ( + credential_organization_uuid is not None + and requested_organization_uuid is not None + and credential_organization_uuid != requested_organization_uuid + ): + return credential, None + + return credential, credential_organization_uuid or requested_organization_uuid + + +def _strongest_auth_state(rows: list[tuple[Any, ...]], current: str) -> str: + result = current + for row in rows: + auth_state = row[0] if isinstance(row, tuple) else row + result = stronger_resource_auth_state(result, auth_state) + return result + + +def get_effective_workflow_auth_state( + db: Session, + user_id: Any, + workflow_id: Any, + organization_id: Any = None, +) -> str: + user_uuid = coerce_uuid(user_id) + workflow, organization_uuid = _workflow_scope(db, workflow_id, organization_id) + if user_uuid is None or workflow is None or organization_uuid is None: + return AUTH_STATE_NONE + + organization = ( + db.query(Organization).filter(Organization.id == organization_uuid).first() + ) + if not organization or not organization.is_active: + return AUTH_STATE_NONE + + manager_state = _manager_state_for_organization(organization, user_uuid) + if manager_state: + return manager_state + + team_rows = ( + db.query(TeamWorkflowPermission.auth_state) + .join(TeamMembership, TeamMembership.team_id == TeamWorkflowPermission.team_id) + .join(Team, Team.id == TeamWorkflowPermission.team_id) + .filter( + TeamMembership.user_id == user_uuid, + TeamWorkflowPermission.workflow_id == workflow.id, + Team.is_active.is_(True), + TeamMembership.grantee_organization_id == organization_uuid, + TeamWorkflowPermission.grantee_organization_id == organization_uuid, + TeamMembership.grantee_organization_id + == TeamWorkflowPermission.grantee_organization_id, + Team.organization_id == organization_uuid, + ) + .all() + ) + direct_rows = ( + db.query(UserWorkflowPermission.auth_state) + .filter( + UserWorkflowPermission.user_id == user_uuid, + UserWorkflowPermission.workflow_id == workflow.id, + UserWorkflowPermission.grantee_organization_id == organization_uuid, + ) + .all() + ) + + effective_state = _strongest_auth_state(team_rows, AUTH_STATE_NONE) + return _strongest_auth_state(direct_rows, effective_state) + + +def has_workflow_permission( + db: Session, + user_id: Any, + workflow_id: Any, + action: str, + organization_id: Any = None, +) -> bool: + auth_state = get_effective_workflow_auth_state( + db, user_id, workflow_id, organization_id=organization_id + ) + return workflow_auth_state_allows(auth_state, action) + + +def get_effective_llm_credential_auth_state( + db: Session, + user_id: Any, + llm_credential_id: Any, + organization_id: Any = None, +) -> str: + user_uuid = coerce_uuid(user_id) + credential, organization_uuid = _llm_credential_scope( + db, llm_credential_id, organization_id + ) + if user_uuid is None or credential is None: + return AUTH_STATE_NONE + + if organization_uuid is None: + if _same_uuid(credential.user_id, user_uuid): + return AUTH_STATE_MANAGER + return AUTH_STATE_NONE + + organization = ( + db.query(Organization).filter(Organization.id == organization_uuid).first() + ) + if not organization or not organization.is_active: + return AUTH_STATE_NONE + + manager_state = _manager_state_for_organization(organization, user_uuid) + if manager_state: + return manager_state + + team_rows = ( + db.query(TeamLLMPermission.auth_state) + .join(TeamMembership, TeamMembership.team_id == TeamLLMPermission.team_id) + .join(Team, Team.id == TeamLLMPermission.team_id) + .filter( + TeamMembership.user_id == user_uuid, + TeamLLMPermission.llm_credential_id == credential.id, + Team.is_active.is_(True), + TeamMembership.grantee_organization_id == organization_uuid, + TeamLLMPermission.grantee_organization_id == organization_uuid, + TeamMembership.grantee_organization_id + == TeamLLMPermission.grantee_organization_id, + Team.organization_id == organization_uuid, + ) + .all() + ) + direct_rows = ( + db.query(UserLLMPermission.auth_state) + .filter( + UserLLMPermission.user_id == user_uuid, + UserLLMPermission.llm_credential_id == credential.id, + UserLLMPermission.grantee_organization_id == organization_uuid, + ) + .all() + ) + + effective_state = _strongest_auth_state(team_rows, AUTH_STATE_NONE) + return _strongest_auth_state(direct_rows, effective_state) + + +def has_llm_credential_permission( + db: Session, + user_id: Any, + llm_credential_id: Any, + action: str, + organization_id: Any = None, +) -> bool: + auth_state = get_effective_llm_credential_auth_state( + db, + user_id, + llm_credential_id, + organization_id=organization_id, + ) + return llm_credential_auth_state_allows(auth_state, action) + + +def canonical_effective_auth_state(auth_state: Any) -> str: + return normalize_resource_auth_state(auth_state) diff --git a/apps/shared/services/tracing/access.py b/apps/shared/services/tracing/access.py index b3dcb7e89..86724a13c 100644 --- a/apps/shared/services/tracing/access.py +++ b/apps/shared/services/tracing/access.py @@ -395,7 +395,7 @@ def _rbac_decision( if view_level == VIEW_METADATA: allowed = ( visibility.owner_trace_access_enabled - and TraceRbacService.auth_state_at_least(auth_state, "read") + and TraceRbacService.auth_state_at_least(auth_state, "viewer") ) return TraceAccessDecision( allowed, @@ -418,7 +418,7 @@ def _rbac_decision( ) allowed = ( visibility.owner_redacted_payload_access_enabled - and TraceRbacService.auth_state_at_least(auth_state, "write") + and TraceRbacService.auth_state_at_least(auth_state, "builder") ) return TraceAccessDecision( allowed, @@ -437,7 +437,7 @@ def _rbac_decision( ) allowed = ( visibility.owner_raw_payload_access_enabled - and TraceRbacService.auth_state_at_least(auth_state, "admin") + and TraceRbacService.auth_state_at_least(auth_state, "manager") ) return TraceAccessDecision( allowed, diff --git a/apps/shared/services/tracing/rbac.py b/apps/shared/services/tracing/rbac.py index 92a4ba160..5c84d39d7 100644 --- a/apps/shared/services/tracing/rbac.py +++ b/apps/shared/services/tracing/rbac.py @@ -1,17 +1,17 @@ import uuid from typing import Any, Optional, Protocol -from apps.shared.db.models.team import Team, TeamMembership, TeamWorkflowPermission +from apps.shared.permissions import ( + AUTH_STATE_NONE, + AUTH_STATE_RANK, + auth_state_at_least, + normalize_auth_state, +) +from apps.shared.services.permissions import get_effective_workflow_auth_state from sqlalchemy.orm import Session TRACE_SYSTEM_ADMIN_PERMISSION = "tracing.system_admin" -TRACE_AUTH_STATE_RANK = { - "none": 0, - "read": 1, - "write": 2, - "execute": 3, - "admin": 4, -} +TRACE_AUTH_STATE_RANK = AUTH_STATE_RANK class TraceRbacProvider(Protocol): @@ -58,64 +58,29 @@ def get_workflow_auth_state( workflow_id: Any, organization_id: Any = None, ) -> Optional[str]: - """사용자가 workflow에 대해 가진 가장 높은 team permission 권한을 반환합니다.""" + """사용자가 workflow에 대해 가진 가장 높은 MVP auth_state를 반환합니다.""" user_id = cls._coerce_uuid(getattr(user, "id", None)) workflow_uuid = cls._coerce_uuid(workflow_id) - organization_uuid = cls._coerce_uuid(organization_id) if db is None or user_id is None or workflow_uuid is None: return None - query = ( - db.query(TeamWorkflowPermission.auth_state) - .join( - TeamMembership, - TeamMembership.team_id == TeamWorkflowPermission.team_id, - ) - .join( - Team, - Team.id == TeamWorkflowPermission.team_id, - ) - .filter( - TeamMembership.user_id == user_id, - TeamWorkflowPermission.workflow_id == workflow_uuid, - Team.is_active.is_(True), - TeamMembership.grantee_organization_id - == TeamWorkflowPermission.grantee_organization_id, - Team.organization_id - == TeamWorkflowPermission.grantee_organization_id, - ) + auth_state = get_effective_workflow_auth_state( + db, + user_id, + workflow_uuid, + organization_id=organization_id, ) - if organization_uuid is not None: - query = query.filter( - TeamWorkflowPermission.grantee_organization_id == organization_uuid - ) - - best_state: Optional[str] = None - best_rank = TRACE_AUTH_STATE_RANK["none"] - for (auth_state,) in query.all(): - normalized_state = cls.normalize_auth_state(auth_state) - rank = TRACE_AUTH_STATE_RANK[normalized_state] - if rank > best_rank: - best_state = normalized_state - best_rank = rank - - return best_state + if auth_state == AUTH_STATE_NONE: + return None + return auth_state @classmethod def auth_state_at_least(cls, auth_state: Any, minimum: str) -> bool: - normalized_state = cls.normalize_auth_state(auth_state) - normalized_minimum = cls.normalize_auth_state(minimum) - return ( - TRACE_AUTH_STATE_RANK[normalized_state] - >= TRACE_AUTH_STATE_RANK[normalized_minimum] - ) + return auth_state_at_least(auth_state, minimum) @staticmethod def normalize_auth_state(auth_state: Any) -> str: - value = str(auth_state or "none").lower() - if value not in TRACE_AUTH_STATE_RANK: - return "none" - return value + return normalize_auth_state(auth_state) @staticmethod def _coerce_uuid(value: Any) -> Optional[uuid.UUID]: diff --git a/apps/shared/tests/audit/test_audit_listeners.py b/apps/shared/tests/audit/test_audit_listeners.py index 856803d90..70639c6f0 100644 --- a/apps/shared/tests/audit/test_audit_listeners.py +++ b/apps/shared/tests/audit/test_audit_listeners.py @@ -14,6 +14,8 @@ TeamLLMPermission, TeamMembership, TeamWorkflowPermission, + UserLLMPermission, + UserWorkflowPermission, ) from apps.shared.db.models.workflow import Workflow from apps.shared.db.models.workflow_deployment import WorkflowDeployment @@ -60,6 +62,8 @@ def test_layer_b_tracks_security_and_deployment_models(): TeamKnowledgePermission: "team_knowledge_permission", TeamLLMPermission: "team_llm_permission", TeamAuditPermission: "team_audit_permission", + UserWorkflowPermission: "user_workflow_permission", + UserLLMPermission: "user_llm_permission", TraceRedactionPolicy: "trace_redaction_policy", TraceRetentionPolicy: "trace_retention_policy", TraceVisibilityPolicy: "trace_visibility_policy", diff --git a/apps/shared/tests/services/test_permissions.py b/apps/shared/tests/services/test_permissions.py new file mode 100644 index 000000000..4198f5424 --- /dev/null +++ b/apps/shared/tests/services/test_permissions.py @@ -0,0 +1,299 @@ +import uuid +from types import SimpleNamespace + +from apps.shared.permissions import ( + auth_state_at_least, + llm_credential_auth_state_allows, + normalize_auth_state, + normalize_resource_auth_state, + workflow_auth_state_allows, +) +from apps.shared.services.permissions import ( + get_effective_llm_credential_auth_state, + get_effective_workflow_auth_state, + has_llm_credential_permission, + has_workflow_permission, +) + + +class FakeQuery: + def __init__(self, db): + self.db = db + + def join(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + def first(self): + return self.db.first_values.pop(0) + + def all(self): + return self.db.all_values.pop(0) + + +class FakeDb: + def __init__(self, first_values=None, all_values=None): + self.first_values = list(first_values or []) + self.all_values = list(all_values or []) + + def query(self, *args, **kwargs): + return FakeQuery(self) + + +def test_legacy_auth_states_normalize_to_mvp_auth_states(): + assert normalize_auth_state("read") == "viewer" + assert normalize_auth_state("execute") == "operator" + assert normalize_auth_state("write") == "builder" + assert normalize_auth_state("admin") == "manager" + assert auth_state_at_least("admin", "manager") is True + + +def test_workflow_permission_action_matrix(): + assert workflow_auth_state_allows("viewer", "read") is True + assert workflow_auth_state_allows("viewer", "execute") is False + assert workflow_auth_state_allows("operator", "execute") is True + assert workflow_auth_state_allows("operator", "write") is False + assert workflow_auth_state_allows("builder", "write") is True + assert workflow_auth_state_allows("builder", "deploy") is False + assert workflow_auth_state_allows("manager", "manage") is True + + +def test_audit_only_states_fail_closed_for_resource_permissions(): + assert normalize_resource_auth_state("auditor") == "none" + assert normalize_resource_auth_state("raw_auditor") == "none" + assert workflow_auth_state_allows("auditor", "read") is False + assert workflow_auth_state_allows("raw_auditor", "execute") is False + assert llm_credential_auth_state_allows("raw_auditor", "use") is False + + +def test_organization_owner_gets_manager_for_workflow(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=user_id, + managed_by=None, + is_active=True, + ), + ] + ) + + assert ( + get_effective_workflow_auth_state(db, user_id, workflow_id, organization_id) + == "manager" + ) + + +def test_workflow_permissions_fail_closed_without_rows(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[], []], + ) + + assert ( + get_effective_workflow_auth_state(db, user_id, workflow_id, organization_id) + == "none" + ) + + +def test_direct_workflow_permission_is_additive_over_team_permission(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[("viewer",)], [("builder",)]], + ) + + assert ( + get_effective_workflow_auth_state(db, user_id, workflow_id, organization_id) + == "builder" + ) + + +def test_weaker_direct_workflow_permission_does_not_lower_team_permission(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[("manager",)], [("viewer",)]], + ) + + assert ( + get_effective_workflow_auth_state(db, user_id, workflow_id, organization_id) + == "manager" + ) + + +def test_audit_only_workflow_permission_does_not_override_valid_resource_permission(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[("viewer",), ("raw_auditor",)], []], + ) + + assert ( + get_effective_workflow_auth_state(db, user_id, workflow_id, organization_id) + == "viewer" + ) + + +def test_llm_credential_use_requires_operator_or_builder(): + assert llm_credential_auth_state_allows("viewer", "use") is False + assert llm_credential_auth_state_allows("operator", "use") is True + assert llm_credential_auth_state_allows("builder", "use") is True + + +def test_llm_credential_write_requires_manager(): + assert llm_credential_auth_state_allows("builder", "write") is False + assert llm_credential_auth_state_allows("manager", "write") is True + + +def test_llm_credential_effective_permission_allows_direct_operator_use(): + user_id = uuid.uuid4() + owner_id = uuid.uuid4() + organization_id = uuid.uuid4() + credential_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace( + id=credential_id, + user_id=owner_id, + organization_id=organization_id, + ), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[], [("operator",)]], + ) + + assert ( + get_effective_llm_credential_auth_state( + db, user_id, credential_id, organization_id + ) + == "operator" + ) + + +def test_llm_credential_viewer_cannot_use(): + user_id = uuid.uuid4() + owner_id = uuid.uuid4() + organization_id = uuid.uuid4() + credential_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace( + id=credential_id, + user_id=owner_id, + organization_id=organization_id, + ), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[], [("viewer",)]], + ) + + assert has_llm_credential_permission( + db, user_id, credential_id, "use", organization_id + ) is False + + +def test_audit_only_llm_permission_does_not_grant_use(): + user_id = uuid.uuid4() + owner_id = uuid.uuid4() + organization_id = uuid.uuid4() + credential_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace( + id=credential_id, + user_id=owner_id, + organization_id=organization_id, + ), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[("raw_auditor",)], []], + ) + + assert has_llm_credential_permission( + db, user_id, credential_id, "use", organization_id + ) is False + + +def test_has_workflow_permission_uses_effective_auth_state(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[[("operator",)], []], + ) + + assert has_workflow_permission( + db, user_id, workflow_id, "execute", organization_id + ) is True diff --git a/apps/shared/tests/services/test_tracing_access.py b/apps/shared/tests/services/test_tracing_access.py index df5d5c8ac..78d40270f 100644 --- a/apps/shared/tests/services/test_tracing_access.py +++ b/apps/shared/tests/services/test_tracing_access.py @@ -160,27 +160,56 @@ def test_system_admin_requires_rbac_provider(): def test_trace_rbac_service_selects_highest_workflow_auth_state(): class FakeQuery: + def __init__(self, db): + self.db = db + def join(self, *args, **kwargs): return self def filter(self, *args, **kwargs): return self + def first(self): + return self.db.first_values.pop(0) + def all(self): - return [("read",), ("execute",), ("unknown",)] + return self.db.all_values.pop(0) class FakeDb: + def __init__(self): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + self.user = SimpleNamespace(id=user_id) + self.workflow_id = workflow_id + self.organization_id = organization_id + self.first_values = [ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ] + self.all_values = [ + [("read",), ("execute",), ("unknown",)], + [], + ] + def query(self, *args, **kwargs): - return FakeQuery() + return FakeQuery(self) + + db = FakeDb() auth_state = TraceRbacService.get_workflow_auth_state( - FakeDb(), - SimpleNamespace(id=uuid.uuid4()), - workflow_id=uuid.uuid4(), - organization_id=uuid.uuid4(), + db, + db.user, + workflow_id=db.workflow_id, + organization_id=db.organization_id, ) - assert auth_state == "execute" + assert auth_state == "operator" def test_rbac_read_grants_metadata_access(monkeypatch): @@ -203,14 +232,14 @@ def test_rbac_read_grants_metadata_access(monkeypatch): monkeypatch.setattr( TraceRbacService, "get_workflow_auth_state", - lambda db, actor, workflow, organization_id=None: "read", + lambda db, actor, workflow, organization_id=None: "viewer", ) decision = TraceAccessService.check_trace_access(None, run, user) assert decision.allowed is True assert decision.reason_code == "rbac_metadata" - assert decision.rbac_auth_state == "read" + assert decision.rbac_auth_state == "viewer" def test_rbac_read_does_not_grant_redacted_payload(monkeypatch): @@ -228,7 +257,7 @@ def test_rbac_read_does_not_grant_redacted_payload(monkeypatch): monkeypatch.setattr( TraceRbacService, "get_workflow_auth_state", - lambda db, actor, workflow, organization_id=None: "read", + lambda db, actor, workflow, organization_id=None: "viewer", ) decision = TraceAccessService.check_trace_access( @@ -239,7 +268,7 @@ def test_rbac_read_does_not_grant_redacted_payload(monkeypatch): assert decision.reason_code == "rbac_redacted_payload_access_disabled" -def test_rbac_write_grants_redacted_payload_when_policy_allows(monkeypatch): +def test_rbac_builder_grants_redacted_payload_when_policy_allows(monkeypatch): app_id = uuid.uuid4() run = SimpleNamespace(id=uuid.uuid4(), app_id=app_id, workflow_id=uuid.uuid4()) user = SimpleNamespace(id=uuid.uuid4()) @@ -254,7 +283,7 @@ def test_rbac_write_grants_redacted_payload_when_policy_allows(monkeypatch): monkeypatch.setattr( TraceRbacService, "get_workflow_auth_state", - lambda db, actor, workflow, organization_id=None: "write", + lambda db, actor, workflow, organization_id=None: "builder", ) decision = TraceAccessService.check_trace_access( @@ -265,7 +294,7 @@ def test_rbac_write_grants_redacted_payload_when_policy_allows(monkeypatch): assert decision.reason_code == "rbac_redacted" -def test_rbac_admin_raw_access_still_requires_visibility_policy(monkeypatch): +def test_rbac_manager_raw_access_still_requires_visibility_policy(monkeypatch): app_id = uuid.uuid4() run = SimpleNamespace(id=uuid.uuid4(), app_id=app_id, workflow_id=uuid.uuid4()) user = SimpleNamespace(id=uuid.uuid4()) @@ -280,7 +309,7 @@ def test_rbac_admin_raw_access_still_requires_visibility_policy(monkeypatch): monkeypatch.setattr( TraceRbacService, "get_workflow_auth_state", - lambda db, actor, workflow, organization_id=None: "admin", + lambda db, actor, workflow, organization_id=None: "manager", ) decision = TraceAccessService.check_trace_access(None, run, user, view_level="raw") @@ -289,7 +318,7 @@ def test_rbac_admin_raw_access_still_requires_visibility_policy(monkeypatch): assert decision.reason_code == "rbac_raw_payload_access_disabled" -def test_rbac_admin_raw_access_allowed_when_policy_allows(monkeypatch): +def test_rbac_manager_raw_access_allowed_when_policy_allows(monkeypatch): app_id = uuid.uuid4() run = SimpleNamespace(id=uuid.uuid4(), app_id=app_id, workflow_id=uuid.uuid4()) user = SimpleNamespace(id=uuid.uuid4()) @@ -304,7 +333,7 @@ def test_rbac_admin_raw_access_allowed_when_policy_allows(monkeypatch): monkeypatch.setattr( TraceRbacService, "get_workflow_auth_state", - lambda db, actor, workflow, organization_id=None: "admin", + lambda db, actor, workflow, organization_id=None: "manager", ) decision = TraceAccessService.check_trace_access(None, run, user, view_level="raw") diff --git a/tests/db/test_organization_user_schema.py b/tests/db/test_organization_user_schema.py index 41003c8a1..f2a356166 100644 --- a/tests/db/test_organization_user_schema.py +++ b/tests/db/test_organization_user_schema.py @@ -16,9 +16,12 @@ def _load_model_module(module_name: str, relative_path: str): "apps.shared.db.models": root / "apps" / "shared" / "db" / "models", } for name, path in packages.items(): - module = types.ModuleType(name) + module = sys.modules.get(name) or types.ModuleType(name) module.__path__ = [str(path)] sys.modules[name] = module + if "." in name: + parent_name, child_name = name.rsplit(".", 1) + setattr(sys.modules[parent_name], child_name, module) base_spec = importlib.util.spec_from_file_location( "apps.shared.db.base", @@ -27,6 +30,7 @@ def _load_model_module(module_name: str, relative_path: str): base_module = importlib.util.module_from_spec(base_spec) sys.modules["apps.shared.db.base"] = base_module base_spec.loader.exec_module(base_module) + sys.modules["apps.shared.db"].base = base_module spec = importlib.util.spec_from_file_location( module_name, @@ -35,6 +39,9 @@ def _load_model_module(module_name: str, relative_path: str): module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) + if "." in module_name: + parent_name, child_name = module_name.rsplit(".", 1) + setattr(sys.modules[parent_name], child_name, module) return module diff --git a/tests/db/test_team_permission_constraints.py b/tests/db/test_team_permission_constraints.py index d8231cad7..d84aa020f 100644 --- a/tests/db/test_team_permission_constraints.py +++ b/tests/db/test_team_permission_constraints.py @@ -21,9 +21,12 @@ def _load_team_module(): "apps.shared.db.models": root / "apps" / "shared" / "db" / "models", } for name, path in packages.items(): - module = types.ModuleType(name) + module = sys.modules.get(name) or types.ModuleType(name) module.__path__ = [str(path)] sys.modules[name] = module + if "." in name: + parent_name, child_name = name.rsplit(".", 1) + setattr(sys.modules[parent_name], child_name, module) base_spec = importlib.util.spec_from_file_location( "apps.shared.db.base", @@ -32,6 +35,7 @@ def _load_team_module(): base_module = importlib.util.module_from_spec(base_spec) sys.modules["apps.shared.db.base"] = base_module base_spec.loader.exec_module(base_module) + sys.modules["apps.shared.db"].base = base_module team_spec = importlib.util.spec_from_file_location( "apps.shared.db.models.team", @@ -40,6 +44,7 @@ def _load_team_module(): team_module = importlib.util.module_from_spec(team_spec) sys.modules["apps.shared.db.models.team"] = team_module team_spec.loader.exec_module(team_module) + sys.modules["apps.shared.db.models"].team = team_module return team_module @@ -49,6 +54,8 @@ def test_team_tables_use_final_names(): assert team.Team.__tablename__ == "teams" assert team.TeamMembership.__tablename__ == "team_memberships" assert team.TeamWorkflowPermission.__tablename__ == "team_workflow_permissions" + assert team.UserWorkflowPermission.__tablename__ == "user_workflow_permissions" + assert team.UserLLMPermission.__tablename__ == "user_llm_permissions" def test_team_has_unique_team_id_organization_id_constraint(): @@ -110,6 +117,8 @@ def test_team_options_and_flags_columns(): team.TeamKnowledgePermission: "ck_team_knowledge_permissions_flags_nonnegative", team.TeamLLMPermission: "ck_team_llm_permissions_flags_nonnegative", team.TeamAuditPermission: "ck_team_audit_permissions_flags_nonnegative", + team.UserWorkflowPermission: "ck_user_workflow_permissions_flags_nonnegative", + team.UserLLMPermission: "ck_user_llm_permissions_flags_nonnegative", } for model, check_name in expected_check_names.items(): @@ -141,5 +150,30 @@ def test_auth_state_is_only_on_resource_permission_tables(): team.TeamKnowledgePermission, team.TeamLLMPermission, team.TeamAuditPermission, + team.UserWorkflowPermission, + team.UserLLMPermission, ): assert "auth_state" in model.__table__.columns + + +def test_user_direct_permission_tables_are_additive_user_resource_grants(): + team = _load_team_module() + expected_unique_constraints = { + team.UserWorkflowPermission: ( + "uq_user_workflow_permissions_org_user_workflow", + ["grantee_organization_id", "user_id", "workflow_id"], + ), + team.UserLLMPermission: ( + "uq_user_llm_permissions_org_user_credential", + ["grantee_organization_id", "user_id", "llm_credential_id"], + ), + } + + for model, (constraint_name, columns) in expected_unique_constraints.items(): + assert "team_id" not in model.__table__.columns + assert any( + isinstance(constraint, UniqueConstraint) + and constraint.name == constraint_name + and [column.name for column in constraint.columns] == columns + for constraint in model.__table__.constraints + ) diff --git a/tests/services/test_auth_service.py b/tests/services/test_auth_service.py index f24a4d7a9..8af02ab05 100644 --- a/tests/services/test_auth_service.py +++ b/tests/services/test_auth_service.py @@ -37,6 +37,11 @@ def _load_module(module_name: str, path: Path): module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) + if "." in module_name: + parent_name, child_name = module_name.rsplit(".", 1) + parent = sys.modules.get(parent_name) + if parent is not None: + setattr(parent, child_name, module) return module @@ -50,15 +55,27 @@ def _load_module(module_name: str, path: Path): "apps.shared.db.models": ROOT / "apps" / "shared" / "db" / "models", "apps.shared.schemas": ROOT / "apps" / "shared" / "schemas", }.items(): - package = types.ModuleType(package_name) + package = sys.modules.get(package_name) or types.ModuleType(package_name) package.__path__ = [str(package_path)] sys.modules[package_name] = package + if "." in package_name: + parent_name, child_name = package_name.rsplit(".", 1) + setattr(sys.modules[parent_name], child_name, package) _load_module("apps.shared.db.base", ROOT / "apps" / "shared" / "db" / "base.py") user_module = _load_module( "apps.shared.db.models.user", ROOT / "apps" / "shared" / "db" / "models" / "user.py", ) +for module_name, relative_path in ( + ("apps.shared.db.models.organization", "apps/shared/db/models/organization.py"), + ("apps.shared.db.models.workflow", "apps/shared/db/models/workflow.py"), + ("apps.shared.db.models.knowledge", "apps/shared/db/models/knowledge.py"), + ("apps.shared.db.models.llm", "apps/shared/db/models/llm.py"), + ("apps.shared.db.models.team", "apps/shared/db/models/team.py"), +): + if module_name not in sys.modules: + _load_module(module_name, ROOT / relative_path) _load_module( "apps.shared.schemas.auth", ROOT / "apps" / "shared" / "schemas" / "auth.py", @@ -73,33 +90,44 @@ def _load_module(module_name: str, path: Path): class FakeQuery: - def __init__(self, user): - self.user = user + def __init__(self, db): + self.db = db + + def join(self, *args, **kwargs): + return self def filter(self, *args, **kwargs): return self + def order_by(self, *args, **kwargs): + return self + def first(self): - return self.user + return self.db.query_first_result class FakeDB: def __init__(self, user=None): self.user = user + self.objects = [] + self.query_first_result = user self.commit_count = 0 self.refresh_count = 0 def query(self, model): - return FakeQuery(self.user) - - def add(self, user): - if user.id is None: - user.id = uuid.uuid4() - if user.created_at is None: - user.created_at = datetime.now(timezone.utc) - if user.updated_at is None: - user.updated_at = user.created_at - self.user = user + return FakeQuery(self) + + def add(self, obj): + if getattr(obj, "id", None) is None: + obj.id = uuid.uuid4() + if hasattr(obj, "created_at") and obj.created_at is None: + obj.created_at = datetime.now(timezone.utc) + if hasattr(obj, "updated_at") and obj.updated_at is None: + obj.updated_at = obj.created_at + self.objects.append(obj) + if isinstance(obj, User): + self.user = obj + self.query_first_result = None def commit(self): self.commit_count += 1 @@ -206,3 +234,8 @@ def test_signup_sets_last_login_at_for_initial_session(): assert db.user.last_login_at is not None assert db.user.last_login_at.tzinfo is not None assert db.commit_count == 1 + assert {type(obj).__name__ for obj in db.objects} >= { + "Organization", + "Team", + "TeamMembership", + } From 1551825e938df38b08fe61468e62565a98941120 Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:32:42 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=ED=8C=80=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/api.py | 5 + apps/gateway/api/v1/endpoints/teams.py | 364 ++++++++++++++ apps/gateway/services/team_service.py | 467 ++++++++++++++++++ .../services/test_team_service_permissions.py | 222 +++++++++ apps/shared/schemas/team.py | 64 +++ 5 files changed, 1122 insertions(+) create mode 100644 apps/gateway/api/v1/endpoints/teams.py create mode 100644 apps/gateway/services/team_service.py create mode 100644 apps/gateway/tests/services/test_team_service_permissions.py create mode 100644 apps/shared/schemas/team.py diff --git a/apps/gateway/api/api.py b/apps/gateway/api/api.py index b044ba518..133293e47 100644 --- a/apps/gateway/api/api.py +++ b/apps/gateway/api/api.py @@ -13,6 +13,7 @@ rag, run, template_wizard, + teams, tracing, users, webhook, @@ -36,6 +37,10 @@ # 예: api_router.include_router(user.router, prefix="/users", tags=["users"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(teams.router, prefix="/teams", tags=["teams"]) +api_router.include_router( + teams.permissions_router, prefix="/permissions", tags=["permissions"] +) api_router.include_router(llm.router, prefix="/llm", tags=["llm"]) api_router.include_router( prompt_wizard.router, prefix="/prompt-wizard", tags=["prompt-wizard"] diff --git a/apps/gateway/api/v1/endpoints/teams.py b/apps/gateway/api/v1/endpoints/teams.py new file mode 100644 index 000000000..179a9c67c --- /dev/null +++ b/apps/gateway/api/v1/endpoints/teams.py @@ -0,0 +1,364 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from apps.gateway.auth.dependencies import get_current_user +from apps.gateway.services.organization_context import ensure_user_default_organization +from apps.gateway.services.team_service import TeamService +from apps.shared.db.models.llm import LLMCredential +from apps.shared.db.models.user import User +from apps.shared.db.models.workflow import Workflow +from apps.shared.db.session import get_db +from apps.shared.schemas.team import ( + PermissionMutationResponse, + ResourceAuthStateRequest, + ResourcePermissionGrantRequest, + ResourcePermissionRevokeRequest, + TeamCreateRequest, + TeamMembershipRequest, + TeamResponse, + TeamUpdateRequest, +) + +router = APIRouter() +permissions_router = APIRouter() + + +def _workflow_organization_id(db: Session, workflow_id: UUID) -> UUID: + workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + return workflow.organization_id + + +def _credential_organization_id(db: Session, credential_id: UUID) -> UUID: + credential = db.query(LLMCredential).filter(LLMCredential.id == credential_id).first() + if not credential: + raise HTTPException(status_code=404, detail="Credential not found") + return credential.organization_id + + +def _grant_permission( + db: Session, + current_user: User, + organization_id: UUID, + resource_type: str, + resource_id: UUID, + grantee_type: str, + grantee_id: UUID, + auth_state: str, +) -> PermissionMutationResponse: + row = TeamService.grant_resource_permission( + db, + current_user, + ResourcePermissionGrantRequest( + organization_id=organization_id, + resource_type=resource_type, + resource_id=resource_id, + grantee_type=grantee_type, + grantee_id=grantee_id, + auth_state=auth_state, + ), + ) + return PermissionMutationResponse(id=row.id, status="granted") + + +def _revoke_permission( + db: Session, + current_user: User, + organization_id: UUID, + resource_type: str, + resource_id: UUID, + grantee_type: str, + grantee_id: UUID, +) -> PermissionMutationResponse: + TeamService.revoke_resource_permission( + db, + current_user, + ResourcePermissionRevokeRequest( + organization_id=organization_id, + resource_type=resource_type, + resource_id=resource_id, + grantee_type=grantee_type, + grantee_id=grantee_id, + ), + ) + return PermissionMutationResponse(status="revoked") + + +@router.post("", response_model=TeamResponse) +def create_team( + request: TeamCreateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return TeamService.create_team(db, current_user, request) + + +@router.get("", response_model=list[TeamResponse]) +def list_teams( + organization_id: UUID | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + organization_id = organization_id or ensure_user_default_organization( + db, current_user + ) + return TeamService.list_teams(db, current_user, organization_id) + + +@router.patch("/{team_id}", response_model=TeamResponse) +def update_team( + team_id: UUID, + request: TeamUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return TeamService.update_team(db, current_user, team_id, request) + + +@router.delete("/{team_id}") +def deactivate_team( + team_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return TeamService.deactivate_team(db, current_user, team_id) + + +@router.post("/{team_id}/memberships") +def add_membership( + team_id: UUID, + request: TeamMembershipRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + membership = TeamService.add_membership(db, current_user, team_id, request) + return {"id": str(membership.id), "status": "added"} + + +@router.post("/{team_id}/members") +def add_member( + team_id: UUID, + request: TeamMembershipRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return add_membership(team_id, request, db, current_user) + + +@router.delete("/{team_id}/memberships/{user_id}") +def remove_membership( + team_id: UUID, + user_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return TeamService.remove_membership(db, current_user, team_id, user_id) + + +@router.delete("/{team_id}/members/{user_id}") +def remove_member( + team_id: UUID, + user_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return remove_membership(team_id, user_id, db, current_user) + + +@router.post("/permissions", response_model=PermissionMutationResponse) +def grant_resource_permission( + request: ResourcePermissionGrantRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + row = TeamService.grant_resource_permission(db, current_user, request) + return PermissionMutationResponse(id=row.id, status="granted") + + +@router.post("/permissions/revoke", response_model=PermissionMutationResponse) +def revoke_resource_permission( + request: ResourcePermissionRevokeRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + TeamService.revoke_resource_permission(db, current_user, request) + return PermissionMutationResponse(status="revoked") + + +@permissions_router.put( + "/workflows/{workflow_id}/teams/{team_id}", + response_model=PermissionMutationResponse, +) +def grant_workflow_team_permission( + workflow_id: UUID, + team_id: UUID, + request: ResourceAuthStateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _grant_permission( + db, + current_user, + _workflow_organization_id(db, workflow_id), + "workflow", + workflow_id, + "team", + team_id, + request.auth_state, + ) + + +@permissions_router.delete( + "/workflows/{workflow_id}/teams/{team_id}", + response_model=PermissionMutationResponse, +) +def revoke_workflow_team_permission( + workflow_id: UUID, + team_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _revoke_permission( + db, + current_user, + _workflow_organization_id(db, workflow_id), + "workflow", + workflow_id, + "team", + team_id, + ) + + +@permissions_router.put( + "/workflows/{workflow_id}/users/{user_id}", + response_model=PermissionMutationResponse, +) +def grant_workflow_user_permission( + workflow_id: UUID, + user_id: UUID, + request: ResourceAuthStateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _grant_permission( + db, + current_user, + _workflow_organization_id(db, workflow_id), + "workflow", + workflow_id, + "user", + user_id, + request.auth_state, + ) + + +@permissions_router.delete( + "/workflows/{workflow_id}/users/{user_id}", + response_model=PermissionMutationResponse, +) +def revoke_workflow_user_permission( + workflow_id: UUID, + user_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _revoke_permission( + db, + current_user, + _workflow_organization_id(db, workflow_id), + "workflow", + workflow_id, + "user", + user_id, + ) + + +@permissions_router.put( + "/llm-credentials/{credential_id}/teams/{team_id}", + response_model=PermissionMutationResponse, +) +def grant_credential_team_permission( + credential_id: UUID, + team_id: UUID, + request: ResourceAuthStateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _grant_permission( + db, + current_user, + _credential_organization_id(db, credential_id), + "llm_credential", + credential_id, + "team", + team_id, + request.auth_state, + ) + + +@permissions_router.delete( + "/llm-credentials/{credential_id}/teams/{team_id}", + response_model=PermissionMutationResponse, +) +def revoke_credential_team_permission( + credential_id: UUID, + team_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _revoke_permission( + db, + current_user, + _credential_organization_id(db, credential_id), + "llm_credential", + credential_id, + "team", + team_id, + ) + + +@permissions_router.put( + "/llm-credentials/{credential_id}/users/{user_id}", + response_model=PermissionMutationResponse, +) +def grant_credential_user_permission( + credential_id: UUID, + user_id: UUID, + request: ResourceAuthStateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _grant_permission( + db, + current_user, + _credential_organization_id(db, credential_id), + "llm_credential", + credential_id, + "user", + user_id, + request.auth_state, + ) + + +@permissions_router.delete( + "/llm-credentials/{credential_id}/users/{user_id}", + response_model=PermissionMutationResponse, +) +def revoke_credential_user_permission( + credential_id: UUID, + user_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return _revoke_permission( + db, + current_user, + _credential_organization_id(db, credential_id), + "llm_credential", + credential_id, + "user", + user_id, + ) diff --git a/apps/gateway/services/team_service.py b/apps/gateway/services/team_service.py new file mode 100644 index 000000000..648ac1efc --- /dev/null +++ b/apps/gateway/services/team_service.py @@ -0,0 +1,467 @@ +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from apps.gateway.auth.permissions import record_permission_denied +from apps.shared.audit.actions import AuditAction +from apps.shared.audit.logger import record_audit +from apps.shared.db.models.llm import LLMCredential +from apps.shared.db.models.team import ( + Team, + TeamLLMPermission, + TeamMembership, + TeamWorkflowPermission, + UserLLMPermission, + UserWorkflowPermission, +) +from apps.shared.db.models.user import User +from apps.shared.db.models.workflow import Workflow +from apps.shared.permissions import ( + AUTH_STATE_BUILDER, + AUTH_STATE_MANAGER, + AUTH_STATE_NONE, + AUTH_STATE_OPERATOR, + AUTH_STATE_VIEWER, + is_canonical_auth_state, + llm_credential_auth_state_allows, + normalize_auth_state, + workflow_auth_state_allows, +) +from apps.shared.schemas.team import ( + ResourcePermissionGrantRequest, + ResourcePermissionRevokeRequest, + TeamCreateRequest, + TeamMembershipRequest, + TeamUpdateRequest, +) +from apps.shared.services.permissions import ( + get_effective_llm_credential_auth_state, + get_effective_workflow_auth_state, + has_organization_manager_permission, +) + +RESOURCE_AUTH_STATES = { + AUTH_STATE_NONE, + AUTH_STATE_VIEWER, + AUTH_STATE_OPERATOR, + AUTH_STATE_BUILDER, + AUTH_STATE_MANAGER, +} + + +def _ensure_organization_manager( + db: Session, + current_user: User, + organization_id: Any, +) -> None: + if has_organization_manager_permission(db, current_user.id, organization_id): + return + record_permission_denied( + current_user, + "organization", + organization_id, + "manage", + AUTH_STATE_NONE, + ) + raise HTTPException(status_code=403, detail="Forbidden") + + +def _get_team(db: Session, team_id: Any) -> Team: + team = db.query(Team).filter(Team.id == team_id).first() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + return team + + +def _resource_organization_id( + db: Session, + resource_type: str, + resource_id: Any, +) -> Any: + if resource_type == "workflow": + workflow = db.query(Workflow).filter(Workflow.id == resource_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + return workflow.organization_id + + credential = ( + db.query(LLMCredential).filter(LLMCredential.id == resource_id).first() + ) + if not credential: + raise HTTPException(status_code=404, detail="Credential not found") + return credential.organization_id + + +def _ensure_resource_permission_manager( + db: Session, + current_user: User, + resource_type: str, + resource_id: Any, + organization_id: Any, +) -> None: + if has_organization_manager_permission(db, current_user.id, organization_id): + return + + if resource_type == "workflow": + effective_auth_state = get_effective_workflow_auth_state( + db, + current_user.id, + resource_id, + organization_id=organization_id, + ) + if workflow_auth_state_allows(effective_auth_state, "manage"): + return + else: + effective_auth_state = get_effective_llm_credential_auth_state( + db, + current_user.id, + resource_id, + organization_id=organization_id, + ) + if llm_credential_auth_state_allows(effective_auth_state, "manage"): + return + + record_permission_denied( + current_user, + resource_type, + resource_id, + "manage", + effective_auth_state, + ) + raise HTTPException(status_code=403, detail="Forbidden") + + +def _validate_grant_request( + db: Session, + request: ResourcePermissionGrantRequest, +) -> str: + auth_state = normalize_auth_state(request.auth_state) + if ( + not is_canonical_auth_state(request.auth_state) + or auth_state not in RESOURCE_AUTH_STATES + ): + raise HTTPException(status_code=400, detail="Invalid auth_state") + + resource_organization_id = _resource_organization_id( + db, request.resource_type, request.resource_id + ) + if resource_organization_id != request.organization_id: + raise HTTPException(status_code=400, detail="Resource organization mismatch") + return auth_state + + +def _ensure_grantee_user_membership( + db: Session, + user_id: Any, + organization_id: Any, +) -> None: + membership = ( + db.query(TeamMembership) + .join(Team, Team.id == TeamMembership.team_id) + .join(User, User.id == TeamMembership.user_id) + .filter( + TeamMembership.user_id == user_id, + TeamMembership.grantee_organization_id == organization_id, + TeamMembership.grantee_organization_id == Team.organization_id, + Team.organization_id == organization_id, + Team.is_active.is_(True), + User.deactivated_at.is_(None), + ) + .first() + ) + if not membership: + raise HTTPException( + status_code=400, + detail="Grantee user is not a member of the organization", + ) + + +def _record_permission_mutation( + action: str, + current_user: User, + target_id: Any, + request: ResourcePermissionGrantRequest | ResourcePermissionRevokeRequest, + auth_state: str | None = None, +) -> None: + metadata = { + "resource_type": request.resource_type, + "resource_id": str(request.resource_id), + "grantee_type": request.grantee_type, + "grantee_id": str(request.grantee_id), + "organization_id": str(request.organization_id), + } + if auth_state is not None: + metadata["auth_state"] = auth_state + + record_audit( + action=action, + category="action", + actor_id=current_user.id, + actor_type="user", + target_type="permission", + target_id=target_id, + metadata=metadata, + ) + + +class TeamService: + @staticmethod + def list_teams( + db: Session, + current_user: User, + organization_id: Any, + ) -> list[Team]: + _ensure_organization_manager(db, current_user, organization_id) + return ( + db.query(Team) + .filter( + Team.organization_id == organization_id, + Team.is_active.is_(True), + ) + .order_by(Team.created_at.asc()) + .all() + ) + + @staticmethod + def create_team( + db: Session, + current_user: User, + request: TeamCreateRequest, + ) -> Team: + _ensure_organization_manager(db, current_user, request.organization_id) + team = Team( + organization_id=request.organization_id, + name=request.name, + description=request.description, + created_by=current_user.id, + managed_by=current_user.id, + is_auto_add=request.is_auto_add, + ) + db.add(team) + db.commit() + db.refresh(team) + return team + + @staticmethod + def update_team( + db: Session, + current_user: User, + team_id: Any, + request: TeamUpdateRequest, + ) -> Team: + team = _get_team(db, team_id) + _ensure_organization_manager(db, current_user, team.organization_id) + if request.name is not None: + team.name = request.name + if request.description is not None: + team.description = request.description + if request.managed_by is not None: + team.managed_by = request.managed_by + if request.is_auto_add is not None: + team.is_auto_add = request.is_auto_add + db.commit() + db.refresh(team) + return team + + @staticmethod + def deactivate_team(db: Session, current_user: User, team_id: Any) -> dict: + team = _get_team(db, team_id) + _ensure_organization_manager(db, current_user, team.organization_id) + team.is_active = False + team.deactivated_at = datetime.now(timezone.utc) + db.commit() + return {"status": "deactivated"} + + @staticmethod + def add_membership( + db: Session, + current_user: User, + team_id: Any, + request: TeamMembershipRequest, + ) -> TeamMembership: + team = _get_team(db, team_id) + _ensure_organization_manager(db, current_user, team.organization_id) + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.grantee_organization_id == team.organization_id, + TeamMembership.team_id == team.id, + TeamMembership.user_id == request.user_id, + ) + .first() + ) + if membership: + return membership + + membership = TeamMembership( + grantee_organization_id=team.organization_id, + team_id=team.id, + user_id=request.user_id, + assigned_by=current_user.id, + ) + db.add(membership) + db.commit() + db.refresh(membership) + return membership + + @staticmethod + def remove_membership( + db: Session, + current_user: User, + team_id: Any, + user_id: Any, + ) -> dict: + team = _get_team(db, team_id) + _ensure_organization_manager(db, current_user, team.organization_id) + membership = ( + db.query(TeamMembership) + .filter( + TeamMembership.grantee_organization_id == team.organization_id, + TeamMembership.team_id == team.id, + TeamMembership.user_id == user_id, + ) + .first() + ) + if membership: + db.delete(membership) + db.commit() + return {"status": "removed"} + + @staticmethod + def grant_resource_permission( + db: Session, + current_user: User, + request: ResourcePermissionGrantRequest, + ) -> Any: + auth_state = _validate_grant_request(db, request) + _ensure_resource_permission_manager( + db, + current_user, + request.resource_type, + request.resource_id, + request.organization_id, + ) + + if request.grantee_type == "team": + team = _get_team(db, request.grantee_id) + if team.organization_id != request.organization_id: + raise HTTPException(status_code=400, detail="Team organization mismatch") + if not getattr(team, "is_active", True): + raise HTTPException(status_code=400, detail="Team is inactive") + if request.resource_type == "workflow": + model = TeamWorkflowPermission + filters = { + "workflow_id": request.resource_id, + "team_id": request.grantee_id, + } + else: + model = TeamLLMPermission + filters = { + "llm_credential_id": request.resource_id, + "team_id": request.grantee_id, + } + else: + _ensure_grantee_user_membership( + db, + request.grantee_id, + request.organization_id, + ) + if request.resource_type == "workflow": + model = UserWorkflowPermission + filters = { + "workflow_id": request.resource_id, + "user_id": request.grantee_id, + } + else: + model = UserLLMPermission + filters = { + "llm_credential_id": request.resource_id, + "user_id": request.grantee_id, + } + + row = db.query(model).filter( + model.grantee_organization_id == request.organization_id, + *(getattr(model, key) == value for key, value in filters.items()), + ).first() + if not row: + row = model( + grantee_organization_id=request.organization_id, + assigned_by=current_user.id, + **filters, + ) + db.add(row) + row.auth_state = auth_state + db.commit() + db.refresh(row) + _record_permission_mutation( + AuditAction.PERMISSION_GRANT, + current_user, + row.id, + request, + auth_state, + ) + return row + + @staticmethod + def revoke_resource_permission( + db: Session, + current_user: User, + request: ResourcePermissionRevokeRequest, + ) -> dict: + resource_organization_id = _resource_organization_id( + db, request.resource_type, request.resource_id + ) + if resource_organization_id != request.organization_id: + raise HTTPException(status_code=400, detail="Resource organization mismatch") + _ensure_resource_permission_manager( + db, + current_user, + request.resource_type, + request.resource_id, + request.organization_id, + ) + + if request.grantee_type == "team": + if request.resource_type == "workflow": + model = TeamWorkflowPermission + filters = { + "workflow_id": request.resource_id, + "team_id": request.grantee_id, + } + else: + model = TeamLLMPermission + filters = { + "llm_credential_id": request.resource_id, + "team_id": request.grantee_id, + } + else: + if request.resource_type == "workflow": + model = UserWorkflowPermission + filters = { + "workflow_id": request.resource_id, + "user_id": request.grantee_id, + } + else: + model = UserLLMPermission + filters = { + "llm_credential_id": request.resource_id, + "user_id": request.grantee_id, + } + + row = db.query(model).filter( + model.grantee_organization_id == request.organization_id, + *(getattr(model, key) == value for key, value in filters.items()), + ).first() + target_id = row.id if row else None + if row: + db.delete(row) + db.commit() + _record_permission_mutation( + AuditAction.PERMISSION_REVOKE, + current_user, + target_id, + request, + ) + return {"status": "revoked"} diff --git a/apps/gateway/tests/services/test_team_service_permissions.py b/apps/gateway/tests/services/test_team_service_permissions.py new file mode 100644 index 000000000..f94787149 --- /dev/null +++ b/apps/gateway/tests/services/test_team_service_permissions.py @@ -0,0 +1,222 @@ +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from apps.gateway.services import team_service +from apps.gateway.services.team_service import TeamService +from apps.shared.audit.actions import AuditAction +from apps.shared.schemas.team import ( + ResourcePermissionGrantRequest, + ResourcePermissionRevokeRequest, + TeamCreateRequest, +) + + +class FakeQuery: + def __init__(self, db): + self.db = db + + def join(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + def first(self): + return self.db.first_values.pop(0) + + +class FakeDb: + def __init__(self, first_values=None): + self.first_values = list(first_values or []) + self.deleted = [] + self.committed = False + + def query(self, *args, **kwargs): + return FakeQuery(self) + + def add(self, row): + self.added = row + + def delete(self, row): + self.deleted.append(row) + + def commit(self): + self.committed = True + + def refresh(self, row): + self.refreshed = row + + +def test_workflow_resource_manager_can_grant_permission(monkeypatch): + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + grantee_id = uuid.uuid4() + permission_row = SimpleNamespace(id=uuid.uuid4(), auth_state="viewer") + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace(id=uuid.uuid4()), + permission_row, + ] + ) + user = SimpleNamespace(id=uuid.uuid4()) + events = [] + + monkeypatch.setattr(team_service, "has_organization_manager_permission", lambda *a: False) + monkeypatch.setattr( + team_service, + "get_effective_workflow_auth_state", + lambda *a, **k: "manager", + ) + monkeypatch.setattr(team_service, "record_audit", lambda **event: events.append(event)) + + row = TeamService.grant_resource_permission( + db, + user, + ResourcePermissionGrantRequest( + organization_id=organization_id, + resource_type="workflow", + resource_id=workflow_id, + grantee_type="user", + grantee_id=grantee_id, + auth_state="builder", + ), + ) + + assert row is permission_row + assert row.auth_state == "builder" + assert db.committed is True + assert events[0]["action"] == AuditAction.PERMISSION_GRANT + + +def test_user_grant_requires_grantee_organization_membership(monkeypatch): + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + grantee_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + None, + ] + ) + user = SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr(team_service, "has_organization_manager_permission", lambda *a: True) + + with pytest.raises(HTTPException) as exc_info: + TeamService.grant_resource_permission( + db, + user, + ResourcePermissionGrantRequest( + organization_id=organization_id, + resource_type="workflow", + resource_id=workflow_id, + grantee_type="user", + grantee_id=grantee_id, + auth_state="viewer", + ), + ) + + assert exc_info.value.status_code == 400 + + +def test_team_grant_rejects_inactive_team(monkeypatch): + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + team_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=team_id, + organization_id=organization_id, + is_active=False, + ), + ] + ) + user = SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr(team_service, "has_organization_manager_permission", lambda *a: True) + + with pytest.raises(HTTPException) as exc_info: + TeamService.grant_resource_permission( + db, + user, + ResourcePermissionGrantRequest( + organization_id=organization_id, + resource_type="workflow", + resource_id=workflow_id, + grantee_type="team", + grantee_id=team_id, + auth_state="viewer", + ), + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Team is inactive" + + +def test_llm_resource_manager_can_revoke_permission(monkeypatch): + organization_id = uuid.uuid4() + credential_id = uuid.uuid4() + grantee_id = uuid.uuid4() + permission_row = SimpleNamespace(id=uuid.uuid4()) + db = FakeDb( + first_values=[ + SimpleNamespace(id=credential_id, organization_id=organization_id), + permission_row, + ] + ) + user = SimpleNamespace(id=uuid.uuid4()) + events = [] + + monkeypatch.setattr(team_service, "has_organization_manager_permission", lambda *a: False) + monkeypatch.setattr( + team_service, + "get_effective_llm_credential_auth_state", + lambda *a, **k: "manager", + ) + monkeypatch.setattr(team_service, "record_audit", lambda **event: events.append(event)) + + result = TeamService.revoke_resource_permission( + db, + user, + ResourcePermissionRevokeRequest( + organization_id=organization_id, + resource_type="llm_credential", + resource_id=credential_id, + grantee_type="user", + grantee_id=grantee_id, + ), + ) + + assert result == {"status": "revoked"} + assert db.deleted == [permission_row] + assert db.committed is True + assert events[0]["action"] == AuditAction.PERMISSION_REVOKE + + +def test_team_create_still_requires_organization_manager(monkeypatch): + organization_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4()) + denied = [] + + monkeypatch.setattr(team_service, "has_organization_manager_permission", lambda *a: False) + monkeypatch.setattr( + team_service, + "record_permission_denied", + lambda *args, **kwargs: denied.append(args), + ) + + with pytest.raises(HTTPException) as exc_info: + TeamService.create_team( + FakeDb(), + user, + TeamCreateRequest(organization_id=organization_id, name="Builders"), + ) + + assert exc_info.value.status_code == 403 + assert denied[0][1] == "organization" diff --git a/apps/shared/schemas/team.py b/apps/shared/schemas/team.py new file mode 100644 index 000000000..7ab10b42a --- /dev/null +++ b/apps/shared/schemas/team.py @@ -0,0 +1,64 @@ +from datetime import datetime +from typing import Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class TeamCreateRequest(BaseModel): + organization_id: UUID + name: str + description: Optional[str] = None + is_auto_add: bool = False + + +class TeamUpdateRequest(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + managed_by: Optional[UUID] = None + is_auto_add: Optional[bool] = None + + +class TeamMembershipRequest(BaseModel): + user_id: UUID + + +class ResourceAuthStateRequest(BaseModel): + auth_state: str + + +class ResourcePermissionGrantRequest(BaseModel): + organization_id: UUID + resource_type: Literal["workflow", "llm_credential"] + resource_id: UUID + grantee_type: Literal["team", "user"] + grantee_id: UUID + auth_state: str + + +class ResourcePermissionRevokeRequest(BaseModel): + organization_id: UUID + resource_type: Literal["workflow", "llm_credential"] + resource_id: UUID + grantee_type: Literal["team", "user"] + grantee_id: UUID + + +class TeamResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + organization_id: UUID + name: str + description: Optional[str] = None + created_by: UUID + managed_by: Optional[UUID] = None + is_active: bool + is_auto_add: bool + created_at: datetime + updated_at: datetime + + +class PermissionMutationResponse(BaseModel): + id: Optional[UUID] = None + status: str From 81e458ed3fdcacd59c4ec4281be9fc17d1551f17 Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:33:08 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EC=95=B1=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=B0=B0=ED=8F=AC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/v1/endpoints/deployment.py | 37 ++++++++- apps/gateway/api/v1/endpoints/webhook.py | 3 + apps/gateway/api/v1/endpoints/workflow.py | 67 +++++------------ apps/gateway/services/app_service.py | 75 +++++++++++++------ apps/gateway/services/deployment_service.py | 34 ++++++--- apps/gateway/services/workflow_service.py | 12 ++- .../services/test_app_service_permissions.py | 33 ++++++++ 7 files changed, 178 insertions(+), 83 deletions(-) create mode 100644 apps/gateway/tests/services/test_app_service_permissions.py diff --git a/apps/gateway/api/v1/endpoints/deployment.py b/apps/gateway/api/v1/endpoints/deployment.py index 510dfe486..c566e681d 100644 --- a/apps/gateway/api/v1/endpoints/deployment.py +++ b/apps/gateway/api/v1/endpoints/deployment.py @@ -1,19 +1,36 @@ from typing import List -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.orm import Session from apps.gateway.auth.dependencies import get_current_user +from apps.gateway.auth.permissions import ensure_workflow_permission from apps.gateway.utils.audit import audit from apps.gateway.services.deployment_service import DeploymentService from apps.shared.audit.actions import AuditAction +from apps.shared.db.models.app import App from apps.shared.db.models.user import User +from apps.shared.db.models.workflow_deployment import WorkflowDeployment from apps.shared.db.session import get_db from apps.shared.schemas.deployment import DeploymentCreate, DeploymentResponse router = APIRouter() +def _deployment_workflow_id(db: Session, deployment_id: str): + deployment = ( + db.query(WorkflowDeployment) + .filter(WorkflowDeployment.id == deployment_id) + .first() + ) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + app = db.query(App).filter(App.id == deployment.app_id).first() + if not app or not app.workflow_id: + raise HTTPException(status_code=404, detail="Workflow not found") + return app.workflow_id + + @router.post("", response_model=DeploymentResponse) @audit(AuditAction.WORKFLOW_DEPLOY) def create_deployment( @@ -25,6 +42,10 @@ def create_deployment( 워크플로우를 배포합니다. [TEST] bugfix/KAN-000, gateway 배포를 위해 주석 추가 """ + app = db.query(App).filter(App.id == deployment_in.app_id).first() + if not app or not app.workflow_id: + raise HTTPException(status_code=404, detail="App not found") + ensure_workflow_permission(db, current_user, app.workflow_id, "deploy") return DeploymentService.create_deployment(db, deployment_in, current_user.id) @@ -41,6 +62,14 @@ def get_deployments( 특정 앱의 배포 이력을 조회합니다. app_id 또는 workflow_id 중 하나는 필수입니다. """ + target_workflow_id = workflow_id + if app_id and not target_workflow_id: + app = db.query(App).filter(App.id == app_id).first() + if not app: + return [] + target_workflow_id = app.workflow_id + if target_workflow_id: + ensure_workflow_permission(db, current_user, target_workflow_id, "read") return DeploymentService.list_deployments( db, app_id=app_id, @@ -73,6 +102,8 @@ def get_deployment( """ 특정 배포 ID의 상세 정보를 조회합니다. """ + workflow_id = _deployment_workflow_id(db, deployment_id) + ensure_workflow_permission(db, current_user, workflow_id, "read") return DeploymentService.get_deployment(db, deployment_id) @@ -145,6 +176,8 @@ def toggle_deployment( """ from apps.gateway.services.scheduler_service import get_scheduler_service + workflow_id = _deployment_workflow_id(db, deployment_id) + ensure_workflow_permission(db, current_user, workflow_id, "deploy") scheduler = get_scheduler_service() return DeploymentService.toggle_deployment(db, deployment_id, scheduler) @@ -161,5 +194,7 @@ def delete_deployment( """ from apps.gateway.services.scheduler_service import get_scheduler_service + workflow_id = _deployment_workflow_id(db, deployment_id) + ensure_workflow_permission(db, current_user, workflow_id, "manage") scheduler = get_scheduler_service() return DeploymentService.delete_deployment(db, deployment_id, scheduler) diff --git a/apps/gateway/api/v1/endpoints/webhook.py b/apps/gateway/api/v1/endpoints/webhook.py index e840a79a8..1104e94f5 100644 --- a/apps/gateway/api/v1/endpoints/webhook.py +++ b/apps/gateway/api/v1/endpoints/webhook.py @@ -60,6 +60,7 @@ def run_webhook_workflow( app_created_by: str, workflow_id: str, app_id: str, + organization_id: str, ): """ 백그라운드에서 워크플로우를 Celery 태스크로 실행하는 함수 @@ -75,6 +76,7 @@ def run_webhook_workflow( execution_context = { "user_id": app_created_by, "workflow_id": workflow_id, + "organization_id": organization_id, "app_id": app_id, "trigger_mode": "webhook", "deployment_id": deployment_id, @@ -158,6 +160,7 @@ async def receive_webhook( str(app.created_by), str(app.workflow_id) if app.workflow_id else None, str(app.id), + str(app.organization_id) if app.organization_id else None, ) return { diff --git a/apps/gateway/api/v1/endpoints/workflow.py b/apps/gateway/api/v1/endpoints/workflow.py index 5d66cff55..114cb3d73 100644 --- a/apps/gateway/api/v1/endpoints/workflow.py +++ b/apps/gateway/api/v1/endpoints/workflow.py @@ -12,7 +12,9 @@ from starlette.requests import Request from apps.gateway.auth.dependencies import get_current_user +from apps.gateway.auth.permissions import ensure_workflow_permission from apps.gateway.utils.audit import audit +from apps.gateway.services.app_service import AppService from apps.gateway.services.workflow_service import WorkflowService from apps.shared.audit.actions import AuditAction from apps.shared.celery_app import celery_app @@ -52,14 +54,7 @@ def get_workflow_runs( """ skip = (page - 1) * limit - # 워크플로우 접근 권한 체크 (간단히 소유자만) - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - - # TODO: 권한 체크 로직 강화 필요 (협업 기능 등) - # if workflow.created_by != str(current_user.id): - # raise HTTPException(status_code=403, detail="Not authorized") + ensure_workflow_permission(db, current_user, workflow_id, "read") # total = ( # db.query(func.count(WorkflowRun.id)) @@ -93,10 +88,7 @@ def get_workflow_run_detail( """ 특정 워크플로우 실행 이력 상세 조회 """ - # 워크플로우 접근 권한 체크 - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") + ensure_workflow_permission(db, current_user, workflow_id, "read") run = ( db.query(WorkflowRun) @@ -165,9 +157,7 @@ def get_workflow_stats( ) # 1. 권한 체크 - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") + ensure_workflow_permission(db, current_user, workflow_id, "read") # 기간 필터 (기본 30일) cutoff_date = datetime.now() - timedelta(days=days) @@ -400,13 +390,7 @@ def get_workflow( """ 워크플로우 메타데이터 조회 (app_id 포함) """ - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - - if workflow.created_by != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") + workflow = ensure_workflow_permission(db, current_user, workflow_id, "read") return { "id": str(workflow.id), @@ -430,7 +414,7 @@ def list_workflows_by_app( if not app: raise HTTPException(status_code=404, detail="App not found") - if app.created_by != current_user.id: + if not AppService.can_read_app(db, app, current_user.id): raise HTTPException(status_code=403, detail="Forbidden") # 워크플로우 목록 조회 @@ -464,11 +448,7 @@ def sync_draft_workflow( db: 데이터베이스 세션 (의존성 주입) current_user: 현재 로그인한 사용자 """ - # 권한 확인 - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - - if workflow and workflow.created_by != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") + ensure_workflow_permission(db, current_user, workflow_id, "write") return WorkflowService.save_draft( db, workflow_id, request, user_id=str(current_user.id) @@ -484,14 +464,7 @@ def get_draft_workflow( """ PostgreSQL에서 워크플로우 초안 데이터를 조회합니다. (인증 필요) """ - # 권한 확인 - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - - if workflow.created_by != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") + ensure_workflow_permission(db, current_user, workflow_id, "read") return WorkflowService.get_draft(db, workflow_id) @@ -508,13 +481,7 @@ async def execute_workflow( PostgreSQL에서 워크플로우 초안 데이터를 조회하고, Celery 태스크로 실행합니다. (인증 필요) """ # 1. 권한 확인 - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - - if workflow.created_by != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") + workflow = ensure_workflow_permission(db, current_user, workflow_id, "execute") memory_mode_enabled = False if isinstance(user_input, dict): @@ -533,6 +500,9 @@ async def execute_workflow( execution_context = { "user_id": str(current_user.id), "workflow_id": workflow_id, + "organization_id": ( + str(workflow.organization_id) if workflow.organization_id else None + ), "app_id": str(workflow.app_id), "memory_mode": memory_mode_enabled, "request_id": request.headers.get("x-request-id"), @@ -588,13 +558,7 @@ async def stream_workflow( memory_mode_enabled = False # 1. 권한 확인 - workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() - - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - - if workflow.created_by != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") + workflow = ensure_workflow_permission(db, current_user, workflow_id, "execute") # 2. Request에서 FormData 파싱 content_type = request.headers.get("content-type", "") @@ -637,6 +601,9 @@ async def stream_workflow( execution_context = { "user_id": str(current_user.id), "workflow_id": workflow_id, + "organization_id": ( + str(workflow.organization_id) if workflow.organization_id else None + ), "app_id": str(workflow.app_id), "memory_mode": memory_mode_enabled, "trigger_mode": "manual", # 테스트 실행 diff --git a/apps/gateway/services/app_service.py b/apps/gateway/services/app_service.py index 119e249c2..337f6ca71 100644 --- a/apps/gateway/services/app_service.py +++ b/apps/gateway/services/app_service.py @@ -3,9 +3,15 @@ from sqlalchemy.orm import Session, joinedload +from apps.gateway.services.organization_context import ensure_user_default_organization from apps.shared.db.models.app import App from apps.shared.db.models.workflow import Workflow +from apps.shared.db.models.user import User from apps.shared.schemas.app import AppCreateRequest, AppUpdateRequest +from apps.shared.services.permissions import ( + has_organization_manager_permission, + has_workflow_permission, +) class AppService: @@ -28,9 +34,10 @@ def create_app( Returns: 생성된 App 객체 """ - # organization_id가 없으면 user_id를 사용 (유저별 분리) - # if not organization_id: - # organization_id = user_id + # BACKLOG: active organization을 명시적으로 받기 전까지 기본 조직을 사용하는 fallback이다. + # active organization 선택 흐름이 생기면 이 fallback을 제거한다. + if not organization_id: + organization_id = ensure_user_default_organization(db, user_id) # url_slug, auth_secret 생성 url_slug = AppService._generate_url_slug(db, request.name) @@ -79,8 +86,6 @@ def create_app( @staticmethod def _populate_owner_name(db: Session, app: App): """App 객체에 owner_name 속성을 채웁니다.""" - from apps.shared.db.models.user import User - if app.created_by: user = db.query(User).filter(User.id == app.created_by).first() if user: @@ -105,6 +110,38 @@ def _populate_deployment_status(db: Session, app: App): else: setattr(app, "active_deployment_is_active", None) + @staticmethod + def can_read_app(db: Session, app: App, user_id) -> bool: + if app.organization_id and has_organization_manager_permission( + db, user_id, app.organization_id + ): + return True + if app.workflow_id and has_workflow_permission( + db, + user_id, + app.workflow_id, + "read", + organization_id=app.organization_id, + ): + return True + return app.organization_id is None and app.created_by == user_id + + @staticmethod + def can_manage_app(db: Session, app: App, user_id) -> bool: + if app.organization_id and has_organization_manager_permission( + db, user_id, app.organization_id + ): + return True + if app.workflow_id and has_workflow_permission( + db, + user_id, + app.workflow_id, + "manage", + organization_id=app.organization_id, + ): + return True + return app.organization_id is None and app.created_by == user_id + @staticmethod def get_app(db: Session, app_id: str, user_id=None): """ @@ -123,8 +160,7 @@ def get_app(db: Session, app_id: str, user_id=None): if not app: return None - # 소유자 체크 - if user_id and app.created_by != user_id: + if user_id and not AppService.can_read_app(db, app, user_id): return None AppService._populate_owner_name(db, app) @@ -147,17 +183,12 @@ def get_user_apps(db: Session, user_id): db.query(App) # N+1 문제 방지를 위해 active_deployment 관계를 즉시 로딩 (Joined Load) .options(joinedload(App.active_deployment)) - .filter(App.created_by == user_id) .all() ) + apps = [app for app in apps if AppService.can_read_app(db, app, user_id)] - # owner_name 채우기 (모두 동일한 소유자) - from apps.shared.db.models.user import User - - user = db.query(User).filter(User.id == user_id).first() - if user: - for app in apps: - setattr(app, "owner_name", user.name) + for app in apps: + AppService._populate_owner_name(db, app) # 각 앱에 배포 상태 정보 추가 for app in apps: @@ -209,8 +240,7 @@ def update_app(db: Session, app_id: str, request: AppUpdateRequest, user_id): if not app: return None - # 생성자만 수정 가능 - if app.created_by != user_id: + if not AppService.can_manage_app(db, app, user_id): return None # 필드 업데이트 @@ -254,6 +284,8 @@ def clone_app(db: Session, source_app_id: str, user_id: str): source_app = db.query(App).filter(App.id == source_app_id).first() if not source_app: return None + if not AppService.can_read_app(db, source_app, user_id): + return None # 2. 활성 배포 확인 (Active Deployment) if not source_app.active_deployment_id: @@ -277,8 +309,10 @@ def clone_app(db: Session, source_app_id: str, user_id: str): new_slug = AppService._generate_url_slug(db, f"{source_app.name} (복사본)") new_secret = secrets.token_urlsafe(32) + organization_id = ensure_user_default_organization(db, user_id) + new_app = App( - organization_id=user_id, # 복제하는 사람의 organization_id (user_id와 동일 가정) + organization_id=organization_id, name=f"{source_app.name} (복사본)", description=source_app.description, icon=new_icon, @@ -304,7 +338,7 @@ def clone_app(db: Session, source_app_id: str, user_id: str): graph_data = {k: v for k, v in cleaned_snapshot.items() if k != "features"} new_workflow = Workflow( - organization_id=user_id, + organization_id=organization_id, app_id=new_app.id, created_by=user_id, # 스냅샷 기반 데이터 설정 @@ -335,8 +369,7 @@ def delete_app(db: Session, app_id: str, user_id: str): if not app: return None - # 생성자만 삭제 가능 - if app.created_by != user_id: + if not AppService.can_manage_app(db, app, user_id): return None # 1. Circular dependency 해결을 위해 workflow_id 관계 끊기 diff --git a/apps/gateway/services/deployment_service.py b/apps/gateway/services/deployment_service.py index 214dc1f9a..603479ab3 100644 --- a/apps/gateway/services/deployment_service.py +++ b/apps/gateway/services/deployment_service.py @@ -16,6 +16,7 @@ from apps.shared.db.models.workflow import Workflow from apps.shared.db.models.workflow_deployment import DeploymentType, WorkflowDeployment from apps.shared.schemas.deployment import DeploymentCreate +from apps.shared.services.permissions import has_workflow_permission logger = logging.getLogger(__name__) @@ -46,21 +47,26 @@ def create_deployment( if not app: raise HTTPException(status_code=404, detail="App not found") - # 2. 권한 체크 - if app.created_by != user_id: + # 2. Workflow 조회 및 권한 체크 + workflow = db.query(Workflow).filter(Workflow.id == app.workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + if not has_workflow_permission( + db, + user_id, + workflow.id, + "deploy", + organization_id=workflow.organization_id, + ): raise HTTPException( status_code=403, detail="You do not have permission to deploy this app.", ) - # 3. Workflow 조회 (app의 작업실) - workflow = db.query(Workflow).filter(Workflow.id == app.workflow_id).first() - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - # 4. 첫 배포 시 url_slug, auth_secret 생성 if not app.url_slug: - from services.app_service import AppService + from apps.gateway.services.app_service import AppService app.url_slug = AppService._generate_url_slug(db, app.name) @@ -271,7 +277,6 @@ def list_workflow_node_deployments( .join(WorkflowDeployment, App.active_deployment_id == WorkflowDeployment.id) .filter(WorkflowDeployment.type == DeploymentType.WORKFLOW_NODE) .filter(WorkflowDeployment.is_active.is_(True)) - .filter(App.created_by == user_id) # [NEW] 내 앱만 조회 ) if excluded_app_id: @@ -281,6 +286,14 @@ def list_workflow_node_deployments( nodes = [] for app, deployment in results: + if not app.workflow_id or not has_workflow_permission( + db, + user_id, + app.workflow_id, + "read", + organization_id=app.organization_id, + ): + continue nodes.append( { "deployment_id": str(deployment.id), @@ -372,6 +385,9 @@ async def run_deployment( execution_context = { "user_id": str(app.created_by), # UUID를 문자열로 변환 (JSON 직렬화) "workflow_id": str(app.workflow_id) if app.workflow_id else None, + "organization_id": ( + str(app.organization_id) if app.organization_id else None + ), "app_id": str(app.id), "trigger_mode": "app", # 실행 모드 (앱 배포 실행) "deployment_id": str(deployment.id), diff --git a/apps/gateway/services/workflow_service.py b/apps/gateway/services/workflow_service.py index 61c4e874e..45a32b7da 100644 --- a/apps/gateway/services/workflow_service.py +++ b/apps/gateway/services/workflow_service.py @@ -3,6 +3,8 @@ from fastapi import HTTPException from sqlalchemy.orm import Session +from apps.gateway.services.organization_context import ensure_user_default_organization +from apps.gateway.services.app_service import AppService from apps.shared.db.models.app import App from apps.shared.db.models.workflow import Workflow from apps.shared.schemas.workflow import WorkflowCreateRequest, WorkflowDraftRequest @@ -30,12 +32,18 @@ def create_workflow( if not app: raise HTTPException(status_code=404, detail="App not found") - if app.created_by != user_id: + if not AppService.can_manage_app(db, app, user_id): raise HTTPException(status_code=403, detail="Forbidden") + # BACKLOG: ensure_user_default_organization fallback은 organization_id가 비어 있는 + # legacy app 데이터 보정용이다. DB를 초기화하면 필요 없으므로 제거한다. + organization_id = app.organization_id or ensure_user_default_organization( + db, user_id + ) + # 새 워크플로우 생성 workflow = Workflow( - organization_id=user_id, + organization_id=organization_id, app_id=request.app_id, created_by=user_id, graph={ diff --git a/apps/gateway/tests/services/test_app_service_permissions.py b/apps/gateway/tests/services/test_app_service_permissions.py new file mode 100644 index 000000000..d7f670390 --- /dev/null +++ b/apps/gateway/tests/services/test_app_service_permissions.py @@ -0,0 +1,33 @@ +import uuid +from types import SimpleNamespace + +from apps.gateway.services import app_service +from apps.gateway.services.app_service import AppService + + +def test_app_read_allows_primary_workflow_reader(monkeypatch): + app = SimpleNamespace( + organization_id=uuid.uuid4(), + workflow_id=uuid.uuid4(), + created_by=uuid.uuid4(), + ) + user_id = uuid.uuid4() + + monkeypatch.setattr(app_service, "has_organization_manager_permission", lambda *a: False) + monkeypatch.setattr(app_service, "has_workflow_permission", lambda *a, **k: True) + + assert AppService.can_read_app(SimpleNamespace(), app, user_id) is True + + +def test_app_read_denies_non_reader(monkeypatch): + app = SimpleNamespace( + organization_id=uuid.uuid4(), + workflow_id=uuid.uuid4(), + created_by=uuid.uuid4(), + ) + user_id = uuid.uuid4() + + monkeypatch.setattr(app_service, "has_organization_manager_permission", lambda *a: False) + monkeypatch.setattr(app_service, "has_workflow_permission", lambda *a, **k: False) + + assert AppService.can_read_app(SimpleNamespace(), app, user_id) is False From e0a3e2011ac7c8863d9ad03c230a373516287b8f Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:33:47 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20LLM=20credential=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/v1/endpoints/llm.py | 11 ++ apps/gateway/services/llm_service.py | 161 +++++++++++++----- .../services/test_llm_service_permissions.py | 135 +++++++++++++++ apps/shared/schemas/llm.py | 1 + apps/workflow_engine/services/llm_service.py | 95 ++++++++--- apps/workflow_engine/tasks.py | 16 ++ .../tests/nodes/test_llm_node_runtime.py | 2 +- .../workflow/nodes/llm/llm_node.py | 20 ++- 8 files changed, 368 insertions(+), 73 deletions(-) create mode 100644 apps/gateway/tests/services/test_llm_service_permissions.py diff --git a/apps/gateway/api/v1/endpoints/llm.py b/apps/gateway/api/v1/endpoints/llm.py index 36666ec76..cf6a5686a 100644 --- a/apps/gateway/api/v1/endpoints/llm.py +++ b/apps/gateway/api/v1/endpoints/llm.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from apps.gateway.auth.dependencies import get_current_user +from apps.gateway.auth.permissions import ensure_llm_credential_permission from apps.gateway.utils.audit import audit from apps.gateway.services.llm_service import LLMService from apps.shared.audit.actions import AuditAction @@ -91,6 +92,10 @@ def register_credential( """ try: return LLMService.register_credential(db, current_user.id, request) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except HTTPException: + raise except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) except Exception as exc: @@ -108,10 +113,13 @@ def delete_credential( Delete a user credential. """ try: + ensure_llm_credential_permission(db, current_user, credential_id, "write") deleted = LLMService.delete_credential(db, credential_id, current_user.id) if not deleted: raise HTTPException(status_code=404, detail="Credential not found") return {"message": "Credential deleted", "id": str(credential_id)} + except HTTPException: + raise except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) @@ -127,9 +135,12 @@ def sync_credential_models( 해당 크리덴셜 기준으로 모델 매핑을 재동기화합니다. """ try: + ensure_llm_credential_permission(db, current_user, credential_id, "write") return LLMService.sync_credential_models( db, current_user.id, credential_id, purge_unverified=purge_unverified ) + except HTTPException: + raise except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) except Exception as exc: diff --git a/apps/gateway/services/llm_service.py b/apps/gateway/services/llm_service.py index 5d3f5346b..6570eaa81 100644 --- a/apps/gateway/services/llm_service.py +++ b/apps/gateway/services/llm_service.py @@ -6,6 +6,7 @@ import requests from sqlalchemy.orm import Session, joinedload +from apps.gateway.services.organization_context import ensure_user_default_organization from apps.shared.db.models.llm import ( LLMCredential, LLMModel, @@ -19,6 +20,10 @@ LLMModelResponse, LLMProviderResponse, ) +from apps.shared.services.permissions import ( + has_llm_credential_permission, + has_organization_manager_permission, +) from apps.shared.services.llm_client import get_llm_client logger = logging.getLogger(__name__) @@ -513,15 +518,17 @@ def get_user_credentials( db: Session, user_id: uuid.UUID ) -> List[LLMCredentialResponse]: """사용자의 유효한 크리덴셜 목록 조회.""" - creds = ( - db.query(LLMCredential) - .filter( - LLMCredential.user_id == user_id, - LLMCredential.is_valid == True, - ) - .all() + valid_credentials = ( + db.query(LLMCredential).filter(LLMCredential.is_valid == True).all() ) - return [LLMCredentialResponse.model_validate(c) for c in creds] + readable_credentials = [ + credential + for credential in valid_credentials + if has_llm_credential_permission(db, user_id, credential.id, "read") + ] + return [ + LLMCredentialResponse.model_validate(c) for c in readable_credentials + ] @staticmethod def register_credential( @@ -552,6 +559,12 @@ def register_credential( "입력하신 키는 Anthropic 형식을 따르고 있습니다. OpenAI가 아닌 Anthropic을 선택했는지 확인해주세요." ) + organization_id = request.organization_id or ensure_user_default_organization( + db, user_id + ) + if not has_organization_manager_permission(db, user_id, organization_id): + raise PermissionError("Credential creation requires organization manager") + # 2. API 키 검증 및 모델 조회 remote_models = LLMService._fetch_remote_models( provider.base_url, request.api_key, provider.name @@ -565,6 +578,7 @@ def register_credential( new_cred = LLMCredential( provider_id=provider.id, user_id=user_id, + organization_id=organization_id, credential_name=request.credential_name, encrypted_config=config_json, is_valid=True, @@ -599,12 +613,14 @@ def delete_credential( """크리덴셜을 실제 삭제하지 않고 비활성화 처리.""" cred = ( db.query(LLMCredential) - .filter(LLMCredential.id == credential_id, LLMCredential.user_id == user_id) + .filter(LLMCredential.id == credential_id) .first() ) if not cred: return False + if not has_llm_credential_permission(db, user_id, cred.id, "write"): + return False cred.is_valid = False db.commit() @@ -628,13 +644,14 @@ def sync_credential_models( .options(joinedload(LLMCredential.provider)) .filter( LLMCredential.id == credential_id, - LLMCredential.user_id == user_id, ) .first() ) if not cred: raise ValueError("Credential not found") + if not has_llm_credential_permission(db, user_id, cred.id, "write"): + raise ValueError("Credential not found") if not cred.is_valid: raise ValueError("Credential is not valid") @@ -698,15 +715,17 @@ def sync_credential_models( } @staticmethod - def get_client_for_user(db: Session, user_id: uuid.UUID, model_id: str): + def get_client_for_user( + db: Session, + user_id: uuid.UUID, + model_id: str, + organization_id: Optional[uuid.UUID] = None, + ): """ 주어진 model_id를 지원하는 유효한 크리덴셜을 찾습니다. 우선순위: 1. llm_rel_credential_models에서 명시적 권한 확인 (fail-closed) """ - # TODO: Organization 스키마 도입 시 organization_id 지원 추가. - # 현재는 user_id만 필터링합니다. - # 1. 프로바이더를 알기 위해 모델 조회 # 참고: model_id 문자열은 'gpt-4o'처럼 흔한 값일 수 있음. # 동일한 모델명을 제공하는 프로바이더가 여러 개일 수 있으므로(드물지만), 추가 정보가 필요할 수 있음. @@ -724,32 +743,20 @@ def get_client_for_user(db: Session, user_id: uuid.UUID, model_id: str): # 일단 에러 발생시키지 않고 진행하거나, Known 에러로 처리 raise ValueError(f"Unknown model_id: {model_id}") - # [SIMPLIFIED] rel 테이블 조인 대신 프로바이더 매칭으로 단순화 - # 모델의 프로바이더(OpenAI, Anthropic 등)와 일치하는 유효한 크리덴셜을 찾음 - provider_id = target_model.provider_id if target_model else None + # verified credential-model relation과 credential use 권한을 함께 평가한다. + # relation이 없으면 fail-closed로 처리한다. cred = LLMService._get_valid_credential_for_user( - db, user_id=user_id, provider_id=provider_id + db, + user_id=user_id, + model_db_id=target_model.id, + organization_id=organization_id, ) - # [FALLBACK] UUID 불일치 시 이름 기반 매칭 (서버/로컬 DB 차이 대응) - if not cred and target_model and target_model.provider: - cred = ( - db.query(LLMCredential) - .join(LLMProvider) - .filter( - LLMCredential.user_id == user_id, - LLMCredential.is_valid == True, - LLMProvider.name == target_model.provider.name, - ) - .order_by(LLMCredential.updated_at.desc()) - .first() - ) - if not cred: logger.error( f"[LLMService] No valid credential found for user_id={user_id}, model_id='{model_id}'. " f"TargetModel: {target_model.name if target_model else 'None'} (ID: {target_model.id if target_model else 'None'}), " - f"ProviderID: {provider_id}" + f"ProviderID: {target_model.provider_id if target_model else 'None'}" ) raise ValueError( f"유효한 API 키를 찾을 수 없습니다. [설정 > 모델 키 관리]에서 '{model_id}' 모델을 지원하는 API Key를 등록해주세요." @@ -787,14 +794,46 @@ def _get_valid_credential_for_user( db: Session, user_id: uuid.UUID, provider_id: Optional[uuid.UUID] = None, + model_db_id: Optional[uuid.UUID] = None, + organization_id: Optional[uuid.UUID] = None, ) -> Optional[LLMCredential]: + organization_uuid = None + if organization_id: + try: + organization_uuid = uuid.UUID(str(organization_id)) + except (TypeError, ValueError): + return None + query = db.query(LLMCredential).filter( - LLMCredential.user_id == user_id, LLMCredential.is_valid == True, ) + if organization_uuid: + query = query.filter(LLMCredential.organization_id == organization_uuid) if provider_id: query = query.filter(LLMCredential.provider_id == provider_id) - return query.first() + if model_db_id: + query = ( + query.join( + LLMRelCredentialModel, + LLMRelCredentialModel.credential_id == LLMCredential.id, + ) + .filter( + LLMRelCredentialModel.model_id == model_db_id, + LLMRelCredentialModel.is_verified == True, + ) + .order_by(LLMRelCredentialModel.priority.asc()) + ) + + for credential in query.all(): + if has_llm_credential_permission( + db, + user_id, + credential.id, + "use", + organization_id=organization_uuid, + ): + return credential + return None @staticmethod def get_my_available_models( @@ -804,8 +843,8 @@ def get_my_available_models( 사용자의 등록된 크리덴셜을 기반으로 사용 가능한 모든 모델을 반환합니다. llm_rel_credential_models 기준으로 허용된 모델만 반환합니다. """ - models = ( - db.query(LLMModel) + rows = ( + db.query(LLMModel, LLMCredential.id) .join( LLMRelCredentialModel, LLMRelCredentialModel.model_id == LLMModel.id, @@ -816,16 +855,23 @@ def get_my_available_models( ) .options(joinedload(LLMModel.provider)) .filter( - LLMCredential.user_id == user_id, LLMCredential.is_valid == True, LLMRelCredentialModel.is_verified == True, LLMModel.is_active == True, ) - .distinct() .order_by(LLMModel.name) .all() ) + models = [] + seen_model_ids = set() + for model, credential_id in rows: + if model.id in seen_model_ids: + continue + if has_llm_credential_permission(db, user_id, credential_id, "use"): + models.append(model) + seen_model_ids.add(model.id) + return [LLMModelResponse.model_validate(m) for m in models] @staticmethod @@ -836,8 +882,8 @@ def get_my_embedding_models( 사용자의 크리덴셜에 기반하여 사용 가능한 임베딩 모델 목록을 반환합니다. get_my_available_models와 동일하지만 type='embedding'으로 필터링됩니다. """ - models = ( - db.query(LLMModel) + rows = ( + db.query(LLMModel, LLMCredential.id) .join( LLMRelCredentialModel, LLMRelCredentialModel.model_id == LLMModel.id, @@ -848,16 +894,23 @@ def get_my_embedding_models( ) .options(joinedload(LLMModel.provider)) .filter( - LLMCredential.user_id == user_id, LLMCredential.is_valid == True, LLMRelCredentialModel.is_verified == True, LLMModel.is_active == True, LLMModel.type == "embedding", ) - .distinct() .all() ) + models = [] + seen_model_ids = set() + for model, credential_id in rows: + if model.id in seen_model_ids: + continue + if has_llm_credential_permission(db, user_id, credential_id, "use"): + models.append(model) + seen_model_ids.add(model.id) + return [LLMModelResponse.model_validate(m) for m in models] @staticmethod @@ -937,6 +990,8 @@ def log_usage( model_id: str, usage: Dict[str, Any], cost: float, + organization_id: Optional[uuid.UUID] = None, + workflow_id: Optional[uuid.UUID] = None, workflow_run_id: Optional[uuid.UUID] = None, node_id: Optional[str] = None, ) -> Optional[LLMUsageLog]: @@ -965,8 +1020,24 @@ def log_usage( ) return None + organization_uuid = None + if organization_id: + try: + organization_uuid = uuid.UUID(str(organization_id)) + except (TypeError, ValueError): + organization_uuid = None + workflow_uuid = None + if workflow_id: + try: + workflow_uuid = uuid.UUID(str(workflow_id)) + except (TypeError, ValueError): + workflow_uuid = None + credential = LLMService._get_valid_credential_for_user( - db, user_id, model.provider_id + db, + user_id, + model_db_id=model.id, + organization_id=organization_uuid, ) if not credential: logger.error( @@ -976,8 +1047,10 @@ def log_usage( log = LLMUsageLog( user_id=user_id, + organization_id=organization_uuid, credential_id=credential.id, model_id=model.id, + workflow_id=workflow_uuid, workflow_run_id=workflow_run_id, node_id=node_id, prompt_tokens=usage.get("prompt_tokens", 0), diff --git a/apps/gateway/tests/services/test_llm_service_permissions.py b/apps/gateway/tests/services/test_llm_service_permissions.py new file mode 100644 index 000000000..e8abc416f --- /dev/null +++ b/apps/gateway/tests/services/test_llm_service_permissions.py @@ -0,0 +1,135 @@ +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from apps.gateway.api.v1.endpoints import llm as llm_endpoint +from apps.gateway.services import llm_service +from apps.gateway.services.llm_service import LLMService +from apps.shared.schemas.llm import LLMCredentialCreate + + +class FakeQuery: + def __init__(self, value): + self.value = value + + def join(self, *args, **kwargs): + return self + + def options(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def first(self): + return self.value + + def all(self): + return self.value + + +class FakeDb: + def __init__(self, value): + self.value = value + + def query(self, *args, **kwargs): + return FakeQuery(self.value) + + +def test_delete_credential_preserves_permission_http_exception(monkeypatch): + credential_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4()) + seen = {} + + def deny(db, current_user, checked_credential_id, action): + seen["action"] = action + raise HTTPException(status_code=403, detail="Forbidden") + + monkeypatch.setattr(llm_endpoint, "ensure_llm_credential_permission", deny) + + with pytest.raises(HTTPException) as exc_info: + llm_endpoint.delete_credential(credential_id, FakeDb(None), user) + + assert exc_info.value.status_code == 403 + assert seen["action"] == "write" + + +def test_register_credential_checks_organization_manager_before_remote_fetch(monkeypatch): + organization_id = uuid.uuid4() + provider = SimpleNamespace(id=uuid.uuid4(), name="openai", base_url="https://api.example") + request = LLMCredentialCreate( + provider_id=provider.id, + organization_id=organization_id, + credential_name="shared", + api_key="sk-test", + ) + + monkeypatch.setattr(llm_service, "has_organization_manager_permission", lambda *a: False) + monkeypatch.setattr( + LLMService, + "_fetch_remote_models", + lambda *a, **k: pytest.fail("remote fetch should not run before permission check"), + ) + + with pytest.raises(PermissionError): + LLMService.register_credential(FakeDb(provider), uuid.uuid4(), request) + + +def test_get_user_credentials_filters_by_read_permission(monkeypatch): + readable_id = uuid.uuid4() + blocked_id = uuid.uuid4() + credentials = [ + SimpleNamespace(id=readable_id), + SimpleNamespace(id=blocked_id), + ] + seen_actions = [] + + def can_read(db, user_id, credential_id, action): + seen_actions.append(action) + return credential_id == readable_id and action == "read" + + monkeypatch.setattr(llm_service, "has_llm_credential_permission", can_read) + monkeypatch.setattr( + llm_service.LLMCredentialResponse, + "model_validate", + staticmethod(lambda credential: credential), + ) + + result = LLMService.get_user_credentials(FakeDb(credentials), uuid.uuid4()) + + assert result == [credentials[0]] + assert seen_actions == ["read", "read"] + + +def test_get_my_available_models_filters_by_credential_use_permission(monkeypatch): + allowed_credential_id = uuid.uuid4() + blocked_credential_id = uuid.uuid4() + shared_model = SimpleNamespace(id=uuid.uuid4(), name="Shared") + blocked_model = SimpleNamespace(id=uuid.uuid4(), name="Blocked") + rows = [ + (shared_model, blocked_credential_id), + (shared_model, allowed_credential_id), + (blocked_model, blocked_credential_id), + ] + seen_actions = [] + + def can_use(db, user_id, credential_id, action): + seen_actions.append(action) + return credential_id == allowed_credential_id and action == "use" + + monkeypatch.setattr(llm_service, "has_llm_credential_permission", can_use) + monkeypatch.setattr( + llm_service.LLMModelResponse, + "model_validate", + staticmethod(lambda model: model), + ) + + result = LLMService.get_my_available_models(FakeDb(rows), uuid.uuid4()) + + assert result == [shared_model] + assert seen_actions == ["use", "use", "use"] diff --git a/apps/shared/schemas/llm.py b/apps/shared/schemas/llm.py index 08685168f..1213aeb2c 100644 --- a/apps/shared/schemas/llm.py +++ b/apps/shared/schemas/llm.py @@ -43,6 +43,7 @@ class LLMCredentialCreate(BaseModel): - apiKey -> api_key (to be encrypted) """ provider_id: uuid.UUID + organization_id: Optional[uuid.UUID] = None credential_name: str api_key: str = Field(..., description="Raw API Key") # For custom provider override if supported later, otherwise ignored/removed diff --git a/apps/workflow_engine/services/llm_service.py b/apps/workflow_engine/services/llm_service.py index 9aa797162..23b474b5c 100644 --- a/apps/workflow_engine/services/llm_service.py +++ b/apps/workflow_engine/services/llm_service.py @@ -20,6 +20,7 @@ LLMProviderResponse, ) from apps.shared.services.llm_client import get_llm_client +from apps.shared.services.permissions import has_llm_credential_permission logger = logging.getLogger(__name__) @@ -684,15 +685,17 @@ def sync_credential_models( } @staticmethod - def get_client_for_user(db: Session, user_id: uuid.UUID, model_id: str): + def get_client_for_user( + db: Session, + user_id: uuid.UUID, + model_id: str, + organization_id: Optional[uuid.UUID] = None, + ): """ 주어진 model_id를 지원하는 유효한 크리덴셜을 찾습니다. 우선순위: 1. llm_rel_credential_models에서 명시적 권한 확인 (fail-closed) """ - # TODO: Tenant 스키마 도입 시 organization_id 지원 추가. - # 현재는 user_id만 필터링합니다. - # 1. 프로바이더를 알기 위해 모델 조회 # 참고: model_id 문자열은 'gpt-4o'처럼 흔한 값일 수 있음. # 동일한 모델명을 제공하는 프로바이더가 여러 개일 수 있으므로(드물지만), 추가 정보가 필요할 수 있음. @@ -710,32 +713,20 @@ def get_client_for_user(db: Session, user_id: uuid.UUID, model_id: str): # 일단 에러 발생시키지 않고 진행하거나, Known 에러로 처리 raise ValueError(f"Unknown model_id: {model_id}") - # [SIMPLIFIED] rel 테이블 조인 대신 프로바이더 매칭으로 단순화 - # 모델의 프로바이더(OpenAI, Anthropic 등)와 일치하는 유효한 크리덴셜을 찾음 - provider_id = target_model.provider_id if target_model else None + # verified credential-model relation과 credential use 권한을 함께 평가한다. + # relation이 없으면 fail-closed로 처리한다. cred = LLMService._get_valid_credential_for_user( - db, user_id=user_id, provider_id=provider_id + db, + user_id=user_id, + model_db_id=target_model.id, + organization_id=organization_id, ) - # [FALLBACK] UUID 불일치 시 이름 기반 매칭 (서버/로컬 DB 차이 대응) - if not cred and target_model and target_model.provider: - cred = ( - db.query(LLMCredential) - .join(LLMProvider) - .filter( - LLMCredential.user_id == user_id, - LLMCredential.is_valid == True, - LLMProvider.name == target_model.provider.name, - ) - .order_by(LLMCredential.updated_at.desc()) - .first() - ) - if not cred: logger.error( f"[LLMService] No valid credential found for user_id={user_id}, model_id='{model_id}'. " f"TargetModel: {target_model.name if target_model else 'None'} (ID: {target_model.id if target_model else 'None'}), " - f"ProviderID: {provider_id}" + f"ProviderID: {target_model.provider_id if target_model else 'None'}" ) raise ValueError( @@ -774,14 +765,46 @@ def _get_valid_credential_for_user( db: Session, user_id: uuid.UUID, provider_id: Optional[uuid.UUID] = None, + model_db_id: Optional[uuid.UUID] = None, + organization_id: Optional[uuid.UUID] = None, ) -> Optional[LLMCredential]: + organization_uuid = None + if organization_id: + try: + organization_uuid = uuid.UUID(str(organization_id)) + except (TypeError, ValueError): + return None + query = db.query(LLMCredential).filter( - LLMCredential.user_id == user_id, LLMCredential.is_valid == True, ) + if organization_uuid: + query = query.filter(LLMCredential.organization_id == organization_uuid) if provider_id: query = query.filter(LLMCredential.provider_id == provider_id) - return query.first() + if model_db_id: + query = ( + query.join( + LLMRelCredentialModel, + LLMRelCredentialModel.credential_id == LLMCredential.id, + ) + .filter( + LLMRelCredentialModel.model_id == model_db_id, + LLMRelCredentialModel.is_verified == True, + ) + .order_by(LLMRelCredentialModel.priority.asc()) + ) + + for credential in query.all(): + if has_llm_credential_permission( + db, + user_id, + credential.id, + "use", + organization_id=organization_uuid, + ): + return credential + return None @staticmethod def get_my_available_models( @@ -924,6 +947,8 @@ def log_usage( model_id: str, usage: Dict[str, Any], cost: float, + organization_id: Optional[uuid.UUID] = None, + workflow_id: Optional[uuid.UUID] = None, workflow_run_id: Optional[uuid.UUID] = None, node_id: Optional[str] = None, ) -> Optional[LLMUsageLog]: @@ -952,8 +977,24 @@ def log_usage( ) return None + organization_uuid = None + if organization_id: + try: + organization_uuid = uuid.UUID(str(organization_id)) + except (TypeError, ValueError): + organization_uuid = None + workflow_uuid = None + if workflow_id: + try: + workflow_uuid = uuid.UUID(str(workflow_id)) + except (TypeError, ValueError): + workflow_uuid = None + credential = LLMService._get_valid_credential_for_user( - db, user_id, model.provider_id + db, + user_id, + model_db_id=model.id, + organization_id=organization_uuid, ) if not credential: logger.error( @@ -963,8 +1004,10 @@ def log_usage( log = LLMUsageLog( user_id=user_id, + organization_id=organization_uuid, credential_id=credential.id, model_id=model.id, + workflow_id=workflow_uuid, workflow_run_id=workflow_run_id, node_id=node_id, prompt_tokens=usage.get("prompt_tokens", 0), diff --git a/apps/workflow_engine/tasks.py b/apps/workflow_engine/tasks.py index 761e6e3b1..1832bd738 100644 --- a/apps/workflow_engine/tasks.py +++ b/apps/workflow_engine/tasks.py @@ -89,6 +89,7 @@ def execute_deployed_workflow( [GEVENT] WorkflowEngine이 동기화되어 단순화됨. """ + from apps.shared.db.models.app import App from apps.shared.db.models.workflow_deployment import WorkflowDeployment from apps.workflow_engine.workflow.core.workflow_engine import WorkflowEngine @@ -107,10 +108,15 @@ def execute_deployed_workflow( raise ValueError(f"배포된 워크플로우를 찾을 수 없습니다: {workflow_id}") graph = deployment.graph_data + app = session.query(App).filter(App.id == deployment.app_id).first() execution_context["workflow_id"] = workflow_id execution_context["app_id"] = str(deployment.app_id) execution_context["deployment_id"] = str(deployment.id) execution_context["workflow_version"] = deployment.version + if app and not execution_context.get("organization_id"): + execution_context["organization_id"] = ( + str(app.organization_id) if app.organization_id else None + ) sync_result = {} try: @@ -157,6 +163,7 @@ def execute_by_deployment( [GEVENT] WorkflowEngine이 동기화되어 단순화됨. """ + from apps.shared.db.models.app import App from apps.shared.db.models.workflow_deployment import WorkflowDeployment from apps.workflow_engine.workflow.core.workflow_engine import WorkflowEngine @@ -176,7 +183,16 @@ def execute_by_deployment( if not deployment.graph_snapshot: raise ValueError(f"배포 그래프 데이터가 없습니다: {deployment_id}") + app = session.query(App).filter(App.id == deployment.app_id).first() execution_context["app_id"] = str(deployment.app_id) + if app and not execution_context.get("workflow_id"): + execution_context["workflow_id"] = ( + str(app.workflow_id) if app.workflow_id else None + ) + if app and not execution_context.get("organization_id"): + execution_context["organization_id"] = ( + str(app.organization_id) if app.organization_id else None + ) execution_context["deployment_id"] = str(deployment.id) execution_context["workflow_version"] = deployment.version diff --git a/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py b/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py index 7dc5e289f..7fc2aef34 100644 --- a/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py +++ b/apps/workflow_engine/tests/nodes/test_llm_node_runtime.py @@ -111,7 +111,7 @@ def test_llm_node_uses_fallback_model_on_failure(monkeypatch): primary_client = FailingClient() fallback_client = SuccessClient() - def fake_get_client_for_user(db, user_id, model_id): + def fake_get_client_for_user(db, user_id, model_id, organization_id=None): if model_id == "primary-model": return primary_client if model_id == "fallback-model": diff --git a/apps/workflow_engine/workflow/nodes/llm/llm_node.py b/apps/workflow_engine/workflow/nodes/llm/llm_node.py index 12e637eff..3eaa6163d 100644 --- a/apps/workflow_engine/workflow/nodes/llm/llm_node.py +++ b/apps/workflow_engine/workflow/nodes/llm/llm_node.py @@ -102,10 +102,14 @@ def _run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: raise ValueError( "LLM 노드 실행에 유효한 user_id가 필요합니다." ) from exc + organization_id = self.execution_context.get("organization_id") try: client = LLMService.get_client_for_user( - db_session, user_id=user_id, model_id=self.data.model_id + db_session, + user_id=user_id, + model_id=self.data.model_id, + organization_id=organization_id, ) except Exception as primary_client_error: # [FIX] API 키 조회 실패 시 fallback 모델로 시도 @@ -117,7 +121,10 @@ def _run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: ) try: client = LLMService.get_client_for_user( - db_session, user_id=user_id, model_id=fallback_model_id + db_session, + user_id=user_id, + model_id=fallback_model_id, + organization_id=organization_id, ) # fallback 성공 시 model_id도 변경 self.data.model_id = fallback_model_id @@ -239,12 +246,14 @@ def _run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: raise ValueError( "폴백 모델 실행에 유효한 user_id가 필요합니다." ) from exc + organization_id = self.execution_context.get("organization_id") try: fallback_client = LLMService.get_client_for_user( db_session, # 같은 세션 사용 user_id=user_id, model_id=fallback_model_id, + organization_id=organization_id, ) except Exception as e: logger.error(f"[LLMNode] Fallback client load failed: {e}.") @@ -303,6 +312,12 @@ def _run(self, inputs: Dict[str, Any]) -> Dict[str, Any]: model_id=used_model_id, usage=usage, cost=cost, + organization_id=self.execution_context.get( + "organization_id" + ), + workflow_id=self.execution_context.get( + "workflow_id" + ), workflow_run_id=wf_run_uuid, node_id=self.id, ) @@ -467,6 +482,7 @@ def _build_memory_summary(self) -> Optional[str]: db_session, user_id=user_id, model_id=summary_model_id, + organization_id=self.execution_context.get("organization_id"), ) summary_messages = [ { From 38bd6cb3e9151941a07ea5dde3de254fc4a872d7 Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:34:31 +0900 Subject: [PATCH 5/9] =?UTF-8?q?docs:=20MVP1=20RBAC=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/deployments.md | 4 +-- docs/api/llm-credentials.md | 7 +++-- docs/api/organization-rbac.md | 29 ++++++++++--------- .../mvp-1-development-issue-plan.md | 6 ++-- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/api/deployments.md b/docs/api/deployments.md index 4f6ae0388..37e6f9b03 100644 --- a/docs/api/deployments.md +++ b/docs/api/deployments.md @@ -14,9 +14,9 @@ Deployment 생성, 조회, 활성화, public deployment info, run/webhook 계약 | Status | Method | Path | Request | Response | Permission | | --- | --- | --- | --- | --- | --- | | Implemented | `POST` | `/api/v1/deployments` | `DeploymentCreate` | `DeploymentResponse` | workflow `deploy` | -| Implemented | `GET` | `/api/v1/deployments` | query | `DeploymentResponse[]` | deployment read | +| Implemented | `GET` | `/api/v1/deployments` | query | `DeploymentResponse[]` | workflow `read` | | Implemented | `GET` | `/api/v1/deployments/nodes` | query | `dict[]` | workflow `read` | -| Implemented | `GET` | `/api/v1/deployments/{deployment_id}` | 없음 | `DeploymentResponse` | deployment read | +| Implemented | `GET` | `/api/v1/deployments/{deployment_id}` | 없음 | `DeploymentResponse` | workflow `read` | | Implemented | `GET` | `/api/v1/deployments/public/{url_slug}/info` | 없음 | `DeploymentInfoResponse` | public | | Implemented | `PATCH` | `/api/v1/deployments/{deployment_id}/toggle` | 없음 | `DeploymentResponse` | workflow `deploy` | | Implemented | `DELETE` | `/api/v1/deployments/{deployment_id}` | 없음 | message | workflow `manage` | diff --git a/docs/api/llm-credentials.md b/docs/api/llm-credentials.md index ffb1be5ca..bf8b7f06a 100644 --- a/docs/api/llm-credentials.md +++ b/docs/api/llm-credentials.md @@ -19,8 +19,8 @@ LLM provider, model, credential, model pricing, credential-model sync 계약을 | Implemented | `GET` | `/api/v1/llm/my-embedding-models` | 없음 | `LLMModelResponse[]` | authenticated | | Implemented | `GET` | `/api/v1/llm/credentials` | 없음 | `LLMCredentialResponse[]` | credential `read` | | Implemented | `POST` | `/api/v1/llm/credentials` | `LLMCredentialCreate` | `LLMCredentialResponse` | organization `manager` | -| Implemented | `DELETE` | `/api/v1/llm/credentials/{credential_id}` | 없음 | message | credential `manage` | -| Implemented | `POST` | `/api/v1/llm/credentials/{credential_id}/sync-models` | 없음 | sync result | credential `manage` | +| Implemented | `DELETE` | `/api/v1/llm/credentials/{credential_id}` | 없음 | message | credential `write` | +| Implemented | `POST` | `/api/v1/llm/credentials/{credential_id}/sync-models` | 없음 | sync result | credential `write` | | Implemented | `GET` | `/api/v1/llm/stats/top-models` | query | stats | authenticated | | Implemented | `POST` | `/api/v1/llm/models/sync-pricing` | 없음 | result | system admin | | Implemented | `PUT` | `/api/v1/llm/models/{model_id}/pricing` | `LLMModelPricingUpdate` | result | system admin | @@ -32,6 +32,7 @@ LLM provider, model, credential, model pricing, credential-model sync 계약을 | Field | Type | Required | 설명 | | --- | --- | --- | --- | | `provider_id` | UUID | Yes | provider id | +| `organization_id` | UUID | No | credential이 속할 organization. 없으면 active/default organization fallback | | `credential_name` | string | Yes | 표시 이름 | | `api_key` | string | Yes | 원문 API key. 저장 전 암호화해야 한다. | @@ -50,6 +51,8 @@ LLM provider, model, credential, model pricing, credential-model sync 계약을 | `quota_type` | string | quota 유형 | | `quota_limit` | integer | quota limit | | `quota_used` | integer | quota used | +| `created_at` | datetime | 생성 시각 | +| `updated_at` | datetime | 수정 시각 | ## MVP 1 변경 기준 diff --git a/docs/api/organization-rbac.md b/docs/api/organization-rbac.md index f120a0548..fbb852e93 100644 --- a/docs/api/organization-rbac.md +++ b/docs/api/organization-rbac.md @@ -10,7 +10,7 @@ Related ADRs: [ADR-202606271559-active-organization](../decisions/ADR-2026062715 Organization context, team/member 관리, resource permission grant/revoke API 계약을 정의한다. -현재 dev Gateway에는 전용 organization/team 관리 endpoint가 없다. 아래 API는 MVP 1 RBAC foundation 목표 계약이다. +현재 dev Gateway에는 active organization 전용 endpoint가 없다. Team과 resource permission endpoint는 MVP 1 RBAC foundation 기준으로 구현되어 있다. ## Active Organization @@ -26,24 +26,25 @@ Organization context, team/member 관리, resource permission grant/revoke API | Status | Method | Path | Permission | 설명 | | --- | --- | --- | --- | --- | -| Planned | `POST` | `/api/v1/teams` | organization `manager` | team 생성 | -| Planned | `GET` | `/api/v1/teams` | organization `manager` | active organization의 team 목록 | -| Planned | `PATCH` | `/api/v1/teams/{team_id}` | organization `manager` | team 이름/설명/활성 상태 변경 | -| Planned | `POST` | `/api/v1/teams/{team_id}/members` | organization `manager` | user를 team에 추가 | -| Planned | `DELETE` | `/api/v1/teams/{team_id}/members/{user_id}` | organization `manager` | user를 team에서 제거 | +| Implemented | `POST` | `/api/v1/teams` | organization `manager` | team 생성 | +| Implemented | `GET` | `/api/v1/teams` | organization `manager` | active organization의 team 목록 | +| Implemented | `PATCH` | `/api/v1/teams/{team_id}` | organization `manager` | team 이름/설명/관리자 설정 변경 | +| Implemented | `DELETE` | `/api/v1/teams/{team_id}` | organization `manager` | team 비활성화 | +| Implemented | `POST` | `/api/v1/teams/{team_id}/members` | organization `manager` | user를 team에 추가 | +| Implemented | `DELETE` | `/api/v1/teams/{team_id}/members/{user_id}` | organization `manager` | user를 team에서 제거 | ## Resource Permission | Status | Method | Path | Permission | 설명 | | --- | --- | --- | --- | --- | -| Planned | `PUT` | `/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}` | workflow `manage` 또는 organization `manager` | team workflow 권한 부여/수정 | -| Planned | `DELETE` | `/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}` | workflow `manage` 또는 organization `manager` | team workflow 권한 회수 | -| Planned | `PUT` | `/api/v1/permissions/workflows/{workflow_id}/users/{user_id}` | workflow `manage` 또는 organization `manager` | user direct workflow 권한 부여/수정 | -| Planned | `DELETE` | `/api/v1/permissions/workflows/{workflow_id}/users/{user_id}` | workflow `manage` 또는 organization `manager` | user direct workflow 권한 회수 | -| Planned | `PUT` | `/api/v1/permissions/llm-credentials/{credential_id}/teams/{team_id}` | credential `manage` 또는 organization `manager` | team LLM credential 권한 부여/수정 | -| Planned | `DELETE` | `/api/v1/permissions/llm-credentials/{credential_id}/teams/{team_id}` | credential `manage` 또는 organization `manager` | team LLM credential 권한 회수 | -| Planned | `PUT` | `/api/v1/permissions/llm-credentials/{credential_id}/users/{user_id}` | credential `manage` 또는 organization `manager` | user direct LLM credential 권한 부여/수정 | -| Planned | `DELETE` | `/api/v1/permissions/llm-credentials/{credential_id}/users/{user_id}` | credential `manage` 또는 organization `manager` | user direct LLM credential 권한 회수 | +| Implemented | `PUT` | `/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}` | workflow `manage` 또는 organization `manager` | team workflow 권한 부여/수정 | +| Implemented | `DELETE` | `/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}` | workflow `manage` 또는 organization `manager` | team workflow 권한 회수 | +| Implemented | `PUT` | `/api/v1/permissions/workflows/{workflow_id}/users/{user_id}` | workflow `manage` 또는 organization `manager` | user direct workflow 권한 부여/수정 | +| Implemented | `DELETE` | `/api/v1/permissions/workflows/{workflow_id}/users/{user_id}` | workflow `manage` 또는 organization `manager` | user direct workflow 권한 회수 | +| Implemented | `PUT` | `/api/v1/permissions/llm-credentials/{credential_id}/teams/{team_id}` | credential `manage` 또는 organization `manager` | team LLM credential 권한 부여/수정 | +| Implemented | `DELETE` | `/api/v1/permissions/llm-credentials/{credential_id}/teams/{team_id}` | credential `manage` 또는 organization `manager` | team LLM credential 권한 회수 | +| Implemented | `PUT` | `/api/v1/permissions/llm-credentials/{credential_id}/users/{user_id}` | credential `manage` 또는 organization `manager` | user direct LLM credential 권한 부여/수정 | +| Implemented | `DELETE` | `/api/v1/permissions/llm-credentials/{credential_id}/users/{user_id}` | credential `manage` 또는 organization `manager` | user direct LLM credential 권한 회수 | ## Permission Grant 요청 diff --git a/docs/implementation-plan/mvp-1-development-issue-plan.md b/docs/implementation-plan/mvp-1-development-issue-plan.md index 64bd7fd75..3c4e0edfb 100644 --- a/docs/implementation-plan/mvp-1-development-issue-plan.md +++ b/docs/implementation-plan/mvp-1-development-issue-plan.md @@ -356,7 +356,7 @@ Out of Scope: | Credential scope | `llm_credentials.organization_id`를 active organization 기준으로 저장/조회한다. | | Credential read | credential list/preview는 credential `read` 권한 기준으로 제한한다. | | Credential create | 새 credential 생성은 organization owner/manager가 수행한다. 생성 직후 권한 row를 어떻게 만들지는 8.3의 결정에 따른다. | -| Credential write/manage | 기존 credential 삭제/sync-models/권한 관리는 organization owner/manager 또는 해당 credential `manager` 권한 기준으로 제한한다. | +| Credential write/manage | 기존 credential 삭제/sync-models는 credential `write`, 권한 관리는 credential `manage` 기준으로 제한한다. 두 action 모두 organization owner/manager 또는 해당 credential `manager` 권한으로 통과한다. | | Runtime use | workflow engine LLM node가 credential `use` 권한을 확인한다. | | Model relation | model 사용 가능 여부는 `llm_rel_credential_models.is_verified`와 credential permission을 함께 평가한다. | | Usage log | `llm_usage_logs.organization_id`, `workflow_id`, `workflow_run_id`, `node_id`를 가능한 범위에서 채운다. | @@ -387,7 +387,7 @@ Acceptance Criteria: - credential `viewer`는 credential preview 조회만 가능하고 runtime use는 거부된다. - credential `operator` 또는 `builder`는 LLM node 실행에서 credential을 사용할 수 있다. - organization owner/manager는 새 credential을 생성할 수 있다. -- credential `manager`는 기존 credential 삭제/동기화/권한 관리를 할 수 있다. +- credential `manager`는 기존 credential 삭제/동기화/권한 관리를 할 수 있다. 삭제/동기화 action vocabulary는 `write`, 권한 관리는 `manage`를 사용한다. - verified relation이 없는 model은 credential 권한이 있어도 사용할 수 없다. - 권한 없는 credential/model 조합으로 workflow를 실행하면 LLM node 실행 전 또는 실행 중 명확히 차단된다. - 차단 이벤트가 audit에 남는다. @@ -544,7 +544,7 @@ Out of Scope: | 작업 | 내용 | | --- | --- | | Unit test | permission helper, auth_state mapping, user direct additive allow | -| API test | workflow read/write/execute, LLM credential read/use/manage, permission denied audit | +| API test | workflow read/write/execute, LLM credential read/use/write/manage, permission denied audit | | Service test | organization bootstrap, app/workflow organization scope | | Engine test | LLM node runtime credential use check | | Trace test | run/node LLM usage query | From 7e1de3b1eb3c43b9f690343c65b43f5cdf6a9d3b Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:49:15 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20SQLAlchemy=20Row=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20grant=20=ED=8F=89=EA=B0=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/shared/services/permissions.py | 15 ++++- .../shared/tests/services/test_permissions.py | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/shared/services/permissions.py b/apps/shared/services/permissions.py index f9294988b..2cacfff19 100644 --- a/apps/shared/services/permissions.py +++ b/apps/shared/services/permissions.py @@ -130,10 +130,21 @@ def _llm_credential_scope( return credential, credential_organization_uuid or requested_organization_uuid -def _strongest_auth_state(rows: list[tuple[Any, ...]], current: str) -> str: +def _auth_state_from_row(row: Any) -> Any: + row_mapping = getattr(row, "_mapping", None) + if row_mapping is not None: + if "auth_state" in row_mapping: + return row_mapping["auth_state"] + return next(iter(row_mapping.values()), row) + if isinstance(row, tuple): + return row[0] + return row + + +def _strongest_auth_state(rows: list[Any], current: str) -> str: result = current for row in rows: - auth_state = row[0] if isinstance(row, tuple) else row + auth_state = _auth_state_from_row(row) result = stronger_resource_auth_state(result, auth_state) return result diff --git a/apps/shared/tests/services/test_permissions.py b/apps/shared/tests/services/test_permissions.py index 4198f5424..6d980ab2e 100644 --- a/apps/shared/tests/services/test_permissions.py +++ b/apps/shared/tests/services/test_permissions.py @@ -42,6 +42,11 @@ def query(self, *args, **kwargs): return FakeQuery(self) +class FakeSqlAlchemyRow: + def __init__(self, auth_state): + self._mapping = {"auth_state": auth_state} + + def test_legacy_auth_states_normalize_to_mvp_auth_states(): assert normalize_auth_state("read") == "viewer" assert normalize_auth_state("execute") == "operator" @@ -136,6 +141,32 @@ def test_direct_workflow_permission_is_additive_over_team_permission(): ) +def test_workflow_permission_sqlalchemy_rows_are_unpacked_before_ranking(): + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workflow_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace(id=workflow_id, organization_id=organization_id), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[ + [FakeSqlAlchemyRow("viewer")], + [FakeSqlAlchemyRow("builder")], + ], + ) + + assert ( + get_effective_workflow_auth_state(db, user_id, workflow_id, organization_id) + == "builder" + ) + + def test_weaker_direct_workflow_permission_does_not_lower_team_permission(): user_id = uuid.uuid4() organization_id = uuid.uuid4() @@ -223,6 +254,39 @@ def test_llm_credential_effective_permission_allows_direct_operator_use(): ) +def test_llm_permission_sqlalchemy_rows_are_unpacked_before_ranking(): + user_id = uuid.uuid4() + owner_id = uuid.uuid4() + organization_id = uuid.uuid4() + credential_id = uuid.uuid4() + db = FakeDb( + first_values=[ + SimpleNamespace( + id=credential_id, + user_id=owner_id, + organization_id=organization_id, + ), + SimpleNamespace( + id=organization_id, + created_by=uuid.uuid4(), + managed_by=None, + is_active=True, + ), + ], + all_values=[ + [FakeSqlAlchemyRow("viewer")], + [FakeSqlAlchemyRow("operator")], + ], + ) + + assert ( + get_effective_llm_credential_auth_state( + db, user_id, credential_id, organization_id + ) + == "operator" + ) + + def test_llm_credential_viewer_cannot_use(): user_id = uuid.uuid4() owner_id = uuid.uuid4() From 7f9b64a2214343732834e8f440fde7de651fd46c Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 17:51:46 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20workflow=20stats=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=98=A4=EB=A5=98=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/v1/endpoints/workflow.py | 2 ++ .../api/test_workflow_stats_permissions.py | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 apps/gateway/tests/api/test_workflow_stats_permissions.py diff --git a/apps/gateway/api/v1/endpoints/workflow.py b/apps/gateway/api/v1/endpoints/workflow.py index 114cb3d73..b26cccb3a 100644 --- a/apps/gateway/api/v1/endpoints/workflow.py +++ b/apps/gateway/api/v1/endpoints/workflow.py @@ -356,6 +356,8 @@ def get_workflow_stats( failureAnalysis=failure_analysis, recentFailures=recent_failures, ) + except HTTPException: + raise except Exception as e: logger.error(f"[ERROR] Stats API Failed:\n{traceback.format_exc()}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/gateway/tests/api/test_workflow_stats_permissions.py b/apps/gateway/tests/api/test_workflow_stats_permissions.py new file mode 100644 index 000000000..753168681 --- /dev/null +++ b/apps/gateway/tests/api/test_workflow_stats_permissions.py @@ -0,0 +1,24 @@ +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from apps.gateway.api.v1.endpoints import workflow as workflow_endpoint + + +def test_workflow_stats_preserves_permission_http_exception(monkeypatch): + workflow_id = str(uuid.uuid4()) + user = SimpleNamespace(id=uuid.uuid4()) + + def deny(db, current_user, checked_workflow_id, action): + assert checked_workflow_id == workflow_id + assert action == "read" + raise HTTPException(status_code=403, detail="Forbidden") + + monkeypatch.setattr(workflow_endpoint, "ensure_workflow_permission", deny) + + with pytest.raises(HTTPException) as exc_info: + workflow_endpoint.get_workflow_stats(workflow_id, db=object(), current_user=user) + + assert exc_info.value.status_code == 403 From 5e7098b2b1a1b95a4c4aa100dec7f50c701161b9 Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 20:05:25 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20RBAC=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/v1/endpoints/deployment.py | 21 +++- apps/gateway/services/organization_context.py | 3 +- apps/gateway/services/team_service.py | 2 + .../tests/api/test_deployment_permissions.py | 119 ++++++++++++++++++ .../services/test_llm_service_permissions.py | 95 ++++++++++++++ .../services/test_team_service_permissions.py | 7 ++ tests/services/test_auth_service.py | 4 + 7 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 apps/gateway/tests/api/test_deployment_permissions.py diff --git a/apps/gateway/api/v1/endpoints/deployment.py b/apps/gateway/api/v1/endpoints/deployment.py index c566e681d..a1b6caafb 100644 --- a/apps/gateway/api/v1/endpoints/deployment.py +++ b/apps/gateway/api/v1/endpoints/deployment.py @@ -1,3 +1,4 @@ +import uuid from typing import List from fastapi import APIRouter, Depends, HTTPException, Response @@ -63,13 +64,29 @@ def get_deployments( app_id 또는 workflow_id 중 하나는 필수입니다. """ target_workflow_id = workflow_id - if app_id and not target_workflow_id: + if app_id: app = db.query(App).filter(App.id == app_id).first() if not app: return [] + if not app.workflow_id: + return [] target_workflow_id = app.workflow_id - if target_workflow_id: ensure_workflow_permission(db, current_user, target_workflow_id, "read") + if workflow_id: + try: + supplied_workflow_id = uuid.UUID(str(workflow_id)) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, detail="app_id does not match workflow_id" + ) + if uuid.UUID(str(target_workflow_id)) != supplied_workflow_id: + raise HTTPException( + status_code=400, detail="app_id does not match workflow_id" + ) + elif target_workflow_id: + ensure_workflow_permission(db, current_user, target_workflow_id, "read") + else: + return [] return DeploymentService.list_deployments( db, app_id=app_id, diff --git a/apps/gateway/services/organization_context.py b/apps/gateway/services/organization_context.py index a9d85297a..36506b55c 100644 --- a/apps/gateway/services/organization_context.py +++ b/apps/gateway/services/organization_context.py @@ -33,7 +33,7 @@ def ensure_user_default_organization( ) -> uuid.UUID: """Create the default organization/team/membership foundation if missing.""" - user_id = user.id if isinstance(user, User) else user + user_id = getattr(user, "id", user) user_name = getattr(user, "name", None) existing_id = get_user_primary_organization_id(db, user_id) if existing_id: @@ -66,4 +66,5 @@ def ensure_user_default_organization( db.add(organization) db.add(team) db.add(membership) + db.flush() return organization.id diff --git a/apps/gateway/services/team_service.py b/apps/gateway/services/team_service.py index 648ac1efc..d3095b821 100644 --- a/apps/gateway/services/team_service.py +++ b/apps/gateway/services/team_service.py @@ -188,6 +188,8 @@ def _record_permission_mutation( metadata = { "resource_type": request.resource_type, "resource_id": str(request.resource_id), + "grant_subject_type": request.grantee_type, + "grant_subject_id": str(request.grantee_id), "grantee_type": request.grantee_type, "grantee_id": str(request.grantee_id), "organization_id": str(request.organization_id), diff --git a/apps/gateway/tests/api/test_deployment_permissions.py b/apps/gateway/tests/api/test_deployment_permissions.py new file mode 100644 index 000000000..1a05ca89f --- /dev/null +++ b/apps/gateway/tests/api/test_deployment_permissions.py @@ -0,0 +1,119 @@ +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from apps.gateway.api.v1.endpoints import deployment as deployment_endpoint + + +class FakeQuery: + def __init__(self, result): + self.result = result + + def filter(self, *args, **kwargs): + return self + + def first(self): + return self.result + + +class FakeDb: + def __init__(self, app): + self.app = app + + def query(self, *args, **kwargs): + return FakeQuery(self.app) + + +def test_get_deployments_authorizes_app_workflow_when_app_and_workflow_supplied( + monkeypatch, +): + app_workflow_id = uuid.uuid4() + supplied_workflow_id = uuid.uuid4() + app = SimpleNamespace(id=uuid.uuid4(), workflow_id=app_workflow_id) + user = SimpleNamespace(id=uuid.uuid4()) + checked_workflow_ids = [] + + def deny(db, current_user, checked_workflow_id, action): + checked_workflow_ids.append(checked_workflow_id) + raise HTTPException(status_code=403, detail="Forbidden") + + def fail_list(*args, **kwargs): + raise AssertionError("deployments should not be listed without app permission") + + monkeypatch.setattr(deployment_endpoint, "ensure_workflow_permission", deny) + monkeypatch.setattr( + deployment_endpoint.DeploymentService, "list_deployments", fail_list + ) + + with pytest.raises(HTTPException) as exc_info: + deployment_endpoint.get_deployments( + app_id=str(app.id), + workflow_id=str(supplied_workflow_id), + db=FakeDb(app), + current_user=user, + ) + + assert exc_info.value.status_code == 403 + assert checked_workflow_ids == [app_workflow_id] + + +def test_get_deployments_rejects_app_workflow_mismatch_after_authorization( + monkeypatch, +): + app_workflow_id = uuid.uuid4() + supplied_workflow_id = uuid.uuid4() + app = SimpleNamespace(id=uuid.uuid4(), workflow_id=app_workflow_id) + user = SimpleNamespace(id=uuid.uuid4()) + + def allow(db, current_user, checked_workflow_id, action): + assert checked_workflow_id == app_workflow_id + assert action == "read" + + def fail_list(*args, **kwargs): + raise AssertionError("mismatched ids should not reach the service") + + monkeypatch.setattr(deployment_endpoint, "ensure_workflow_permission", allow) + monkeypatch.setattr( + deployment_endpoint.DeploymentService, "list_deployments", fail_list + ) + + with pytest.raises(HTTPException) as exc_info: + deployment_endpoint.get_deployments( + app_id=str(app.id), + workflow_id=str(supplied_workflow_id), + db=FakeDb(app), + current_user=user, + ) + + assert exc_info.value.status_code == 400 + + +def test_get_deployments_accepts_equivalent_workflow_uuid_text(monkeypatch): + app_workflow_id = uuid.uuid4() + app = SimpleNamespace(id=uuid.uuid4(), workflow_id=app_workflow_id) + user = SimpleNamespace(id=uuid.uuid4()) + + def allow(db, current_user, checked_workflow_id, action): + assert checked_workflow_id == app_workflow_id + assert action == "read" + + def list_deployments(*args, **kwargs): + assert kwargs["app_id"] == str(app.id) + assert kwargs["workflow_id"] == str(app_workflow_id).upper() + return ["deployment"] + + monkeypatch.setattr(deployment_endpoint, "ensure_workflow_permission", allow) + monkeypatch.setattr( + deployment_endpoint.DeploymentService, "list_deployments", list_deployments + ) + + result = deployment_endpoint.get_deployments( + app_id=str(app.id), + workflow_id=str(app_workflow_id).upper(), + db=FakeDb(app), + current_user=user, + ) + + assert result == ["deployment"] diff --git a/apps/gateway/tests/services/test_llm_service_permissions.py b/apps/gateway/tests/services/test_llm_service_permissions.py index e8abc416f..97a3893b5 100644 --- a/apps/gateway/tests/services/test_llm_service_permissions.py +++ b/apps/gateway/tests/services/test_llm_service_permissions.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime, timezone from types import SimpleNamespace import pytest @@ -7,6 +8,7 @@ from apps.gateway.api.v1.endpoints import llm as llm_endpoint from apps.gateway.services import llm_service from apps.gateway.services.llm_service import LLMService +from apps.shared.db.models.user import User from apps.shared.schemas.llm import LLMCredentialCreate @@ -41,6 +43,59 @@ def query(self, *args, **kwargs): return FakeQuery(self.value) +class FakeCredentialRegisterQuery: + def __init__(self, db, model): + self.db = db + self.model = model + + def join(self, *args, **kwargs): + return self + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def first(self): + if self.model is llm_service.LLMProvider: + return self.db.provider + if self.model is User: + return SimpleNamespace(name="First User") + return None + + +class FakeCredentialRegisterDb: + def __init__(self, provider): + self.provider = provider + self.added = [] + self.flush_count = 0 + self.committed = False + + def query(self, *args, **kwargs): + return FakeCredentialRegisterQuery(self, args[0]) + + def add(self, row): + self.added.append(row) + + def flush(self): + self.flush_count += 1 + now = datetime.now(timezone.utc) + for row in self.added: + if getattr(row, "id", None) is None: + row.id = uuid.uuid4() + if getattr(row, "created_at", None) is None: + row.created_at = now + if getattr(row, "updated_at", None) is None: + row.updated_at = now + + def commit(self): + self.committed = True + + def refresh(self, row): + self.refreshed = row + + def test_delete_credential_preserves_permission_http_exception(monkeypatch): credential_id = uuid.uuid4() user = SimpleNamespace(id=uuid.uuid4()) @@ -80,6 +135,46 @@ def test_register_credential_checks_organization_manager_before_remote_fetch(mon LLMService.register_credential(FakeDb(provider), uuid.uuid4(), request) +def test_register_credential_flushes_default_organization_before_manager_check( + monkeypatch, +): + user_id = uuid.uuid4() + provider = SimpleNamespace( + id=uuid.uuid4(), name="openai", base_url="https://api.example" + ) + request = LLMCredentialCreate( + provider_id=provider.id, + credential_name="first", + api_key="sk-test", + ) + db = FakeCredentialRegisterDb(provider) + manager_check_flush_counts = [] + + def has_manager_permission(db_arg, checked_user_id, organization_id): + manager_check_flush_counts.append(db_arg.flush_count) + assert checked_user_id == user_id + return db_arg.flush_count > 0 + + monkeypatch.setattr( + llm_service, + "has_organization_manager_permission", + has_manager_permission, + ) + monkeypatch.setattr(LLMService, "_fetch_remote_models", lambda *a, **k: []) + monkeypatch.setattr(LLMService, "_sync_models_to_db", lambda *a, **k: []) + monkeypatch.setattr( + llm_service.LLMCredentialResponse, + "model_validate", + staticmethod(lambda credential: credential), + ) + + credential = LLMService.register_credential(db, user_id, request) + + assert manager_check_flush_counts == [1] + assert credential.organization_id is not None + assert db.committed is True + + def test_get_user_credentials_filters_by_read_permission(monkeypatch): readable_id = uuid.uuid4() blocked_id = uuid.uuid4() diff --git a/apps/gateway/tests/services/test_team_service_permissions.py b/apps/gateway/tests/services/test_team_service_permissions.py index f94787149..a213fb78d 100644 --- a/apps/gateway/tests/services/test_team_service_permissions.py +++ b/apps/gateway/tests/services/test_team_service_permissions.py @@ -90,6 +90,10 @@ def test_workflow_resource_manager_can_grant_permission(monkeypatch): assert row.auth_state == "builder" assert db.committed is True assert events[0]["action"] == AuditAction.PERMISSION_GRANT + assert events[0]["metadata"]["grant_subject_type"] == "user" + assert events[0]["metadata"]["grant_subject_id"] == str(grantee_id) + assert events[0]["metadata"]["resource_type"] == "workflow" + assert events[0]["metadata"]["auth_state"] == "builder" def test_user_grant_requires_grantee_organization_membership(monkeypatch): @@ -197,6 +201,9 @@ def test_llm_resource_manager_can_revoke_permission(monkeypatch): assert db.deleted == [permission_row] assert db.committed is True assert events[0]["action"] == AuditAction.PERMISSION_REVOKE + assert events[0]["metadata"]["grant_subject_type"] == "user" + assert events[0]["metadata"]["grant_subject_id"] == str(grantee_id) + assert events[0]["metadata"]["resource_type"] == "llm_credential" def test_team_create_still_requires_organization_manager(monkeypatch): diff --git a/tests/services/test_auth_service.py b/tests/services/test_auth_service.py index 8af02ab05..3a8a7ddfa 100644 --- a/tests/services/test_auth_service.py +++ b/tests/services/test_auth_service.py @@ -113,6 +113,7 @@ def __init__(self, user=None): self.query_first_result = user self.commit_count = 0 self.refresh_count = 0 + self.flush_count = 0 def query(self, model): return FakeQuery(self) @@ -132,6 +133,9 @@ def add(self, obj): def commit(self): self.commit_count += 1 + def flush(self): + self.flush_count += 1 + def refresh(self, user): self.refresh_count += 1 From 0bed8153557ebeefc7de45b4e3aed01dc5e364ba Mon Sep 17 00:00:00 2001 From: yoonki1207 Date: Sat, 27 Jun 2026 20:19:02 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20LLM=20pricing=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B6=8C=ED=95=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/api/v1/endpoints/llm.py | 17 ++++- .../services/test_llm_service_permissions.py | 73 ++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/apps/gateway/api/v1/endpoints/llm.py b/apps/gateway/api/v1/endpoints/llm.py index cf6a5686a..912dc202c 100644 --- a/apps/gateway/api/v1/endpoints/llm.py +++ b/apps/gateway/api/v1/endpoints/llm.py @@ -21,14 +21,23 @@ LLMModelResponse, LLMProviderResponse, ) +from apps.shared.services.tracing.access import TraceAccessService router = APIRouter() + +def _require_system_admin(db: Session, current_user: User): + if not TraceAccessService.is_system_admin(db, current_user): + raise HTTPException(status_code=403, detail="system_admin_required") + # --- Providers (System) --- @router.get("/providers", response_model=List[LLMProviderResponse]) -def get_system_providers(db: Session = Depends(get_db)): +def get_system_providers( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): """ List all system-defined LLM providers and their models. """ @@ -210,12 +219,13 @@ def get_top_expensive_models( @router.post("/models/sync-pricing") def sync_system_pricing( db: Session = Depends(get_db), - # Optional: Admin only + current_user: User = Depends(get_current_user), ): """ [Admin] Sync all DB models with hardcoded system prices. Useful when system price list is updated. """ + _require_system_admin(db, current_user) try: result = LLMService.sync_system_prices(db) return result @@ -229,11 +239,12 @@ def update_model_pricing( model_id: UUID, pricing: LLMModelPricingUpdate, db: Session = Depends(get_db), - # Optional: Admin only + current_user: User = Depends(get_current_user), ): """ [Admin] Manually update pricing for a specific model. """ + _require_system_admin(db, current_user) try: model = LLMService.update_model_pricing( db, model_id, pricing.input_price_1k, pricing.output_price_1k diff --git a/apps/gateway/tests/services/test_llm_service_permissions.py b/apps/gateway/tests/services/test_llm_service_permissions.py index 97a3893b5..15335ff3e 100644 --- a/apps/gateway/tests/services/test_llm_service_permissions.py +++ b/apps/gateway/tests/services/test_llm_service_permissions.py @@ -9,7 +9,7 @@ from apps.gateway.services import llm_service from apps.gateway.services.llm_service import LLMService from apps.shared.db.models.user import User -from apps.shared.schemas.llm import LLMCredentialCreate +from apps.shared.schemas.llm import LLMCredentialCreate, LLMModelPricingUpdate class FakeQuery: @@ -114,6 +114,77 @@ def deny(db, current_user, checked_credential_id, action): assert seen["action"] == "write" +def _route(path, method): + for route in llm_endpoint.router.routes: + if route.path == path and method in route.methods: + return route + raise AssertionError(f"route not found: {method} {path}") + + +def _dependency_calls(route): + return [dependency.call for dependency in route.dependant.dependencies] + + +def test_llm_catalog_and_pricing_routes_require_current_user(): + protected_routes = [ + ("/providers", "GET"), + ("/models/sync-pricing", "POST"), + ("/models/{model_id}/pricing", "PUT"), + ] + + for path, method in protected_routes: + route = _route(path, method) + assert llm_endpoint.get_current_user in _dependency_calls(route) + + +def test_sync_system_pricing_requires_system_admin(monkeypatch): + user = SimpleNamespace(id=uuid.uuid4()) + + monkeypatch.setattr( + llm_endpoint.TraceAccessService, + "is_system_admin", + staticmethod(lambda db, current_user: False), + ) + monkeypatch.setattr( + LLMService, + "sync_system_prices", + lambda *a, **k: pytest.fail("pricing sync should require system admin"), + ) + + with pytest.raises(HTTPException) as exc_info: + llm_endpoint.sync_system_pricing(db=FakeDb(None), current_user=user) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "system_admin_required" + + +def test_update_model_pricing_requires_system_admin(monkeypatch): + user = SimpleNamespace(id=uuid.uuid4()) + pricing = LLMModelPricingUpdate(input_price_1k=0.1, output_price_1k=0.2) + + monkeypatch.setattr( + llm_endpoint.TraceAccessService, + "is_system_admin", + staticmethod(lambda db, current_user: False), + ) + monkeypatch.setattr( + LLMService, + "update_model_pricing", + lambda *a, **k: pytest.fail("pricing update should require system admin"), + ) + + with pytest.raises(HTTPException) as exc_info: + llm_endpoint.update_model_pricing( + model_id=uuid.uuid4(), + pricing=pricing, + db=FakeDb(None), + current_user=user, + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "system_admin_required" + + def test_register_credential_checks_organization_manager_before_remote_fetch(monkeypatch): organization_id = uuid.uuid4() provider = SimpleNamespace(id=uuid.uuid4(), name="openai", base_url="https://api.example")