Initial deployment setup
Deploy staging / deploy (push) Failing after 6s

This commit is contained in:
root
2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 changed files with 850363 additions and 0 deletions
@@ -0,0 +1 @@
"""Service layer for secretary-api."""
@@ -0,0 +1,376 @@
from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.orm import Session
from app.core.auth import SsoPrincipal
from app.db.models import (
SupportRoleAssignment,
SupportUser,
UserWorkspaceAccess,
Workspace,
)
class AccessService:
ROLE_PRIORITY = {
"SYSTEM_ADMIN": 5,
"SUPER_ADMIN": 4,
"PROJECT_MANAGER": 3,
"FEEDBACK_PROVIDER": 2,
"END_USER": 1,
}
ROLE_PERMISSIONS = {
"SYSTEM_ADMIN": (True, True, True, True),
"SUPER_ADMIN": (True, True, True, True),
"PROJECT_MANAGER": (True, True, True, True),
"FEEDBACK_PROVIDER": (True, True, False, False),
"END_USER": (True, True, False, False),
}
GLOBAL_ADMIN_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN"}
MANAGER_ROLES = {"SYSTEM_ADMIN", "SUPER_ADMIN", "PROJECT_MANAGER"}
SUPPORTED_WORKSPACE_ROLES = {
"PROJECT_MANAGER",
"END_USER",
"FEEDBACK_PROVIDER",
}
@staticmethod
def default_user_workspace_code(db: Session) -> str | None:
"""Return the first active ABC project workspace.
The old Q&A_Platform seed workspace must not determine the first SSO
redirect after ABC projects are synchronized.
"""
return db.execute(
select(Workspace.workspace_code)
.where(
Workspace.is_active.is_(True),
Workspace.workspace_type == "SOFTWARE_APP",
)
.order_by(Workspace.id.asc()),
).scalar()
@staticmethod
def _upsert_support_user(
db: Session,
*,
user_id: str,
tenant_id: str,
email: str | None = None,
name: str | None = None,
department: str | None = None,
phone_number: str | None = None,
tenant_ids: list[str] | None = None,
) -> SupportUser:
"""Create or refresh an SSO identity without concurrent insert races.
The access endpoint is called several times in parallel during the
first SSO redirect. A select-then-insert sequence can deadlock on the
unique (sso_subject, tenant_id) index, so use MySQL's atomic upsert.
Empty profile fields never erase values already synchronized.
"""
table = SupportUser.__table__
statement = mysql_insert(table).values(
sso_subject=user_id,
tenant_id=tenant_id,
email=email,
name=name,
department=department,
phone_number=phone_number,
tenant_ids=tenant_ids or None,
)
statement = statement.on_duplicate_key_update(
email=func.coalesce(statement.inserted.email, table.c.email),
name=func.coalesce(statement.inserted.name, table.c.name),
department=func.coalesce(
statement.inserted.department,
table.c.department,
),
phone_number=func.coalesce(
statement.inserted.phone_number,
table.c.phone_number,
),
tenant_ids=func.coalesce(
statement.inserted.tenant_ids,
table.c.tenant_ids,
),
)
db.execute(statement)
return db.execute(
select(SupportUser).where(
SupportUser.sso_subject == user_id,
SupportUser.tenant_id == tenant_id,
),
).scalar_one()
@staticmethod
def ensure_support_user(db: Session, principal: SsoPrincipal) -> SupportUser:
return AccessService._upsert_support_user(
db,
user_id=principal.user_id,
tenant_id=principal.tenant_id,
email=principal.email,
name=principal.name,
department=principal.department,
phone_number=principal.phone_number,
tenant_ids=principal.tenant_ids,
)
@staticmethod
def ensure_support_user_identity(
db: Session,
*,
user_id: str,
tenant_id: str,
email: str | None = None,
name: str | None = None,
department: str | None = None,
phone_number: str | None = None,
tenant_ids: list[str] | None = None,
) -> SupportUser:
return AccessService._upsert_support_user(
db,
user_id=user_id,
tenant_id=tenant_id,
email=email,
name=name,
department=department,
phone_number=phone_number,
tenant_ids=tenant_ids,
)
@staticmethod
def _role_codes(
db: Session,
user: SupportUser,
workspace_id: int | None = None,
*,
global_only: bool = False,
) -> list[str]:
conditions = [SupportRoleAssignment.support_user_id == user.id]
if global_only:
# Global admin roles are global by role code. Older seed data may
# still contain a workspace_id on these assignments.
conditions.append(
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
)
elif workspace_id is not None:
# Only global administrator roles apply to every workspace. A
# project-scoped role with a NULL workspace is not a shortcut to
# global access.
conditions.append(
or_(
SupportRoleAssignment.workspace_id == workspace_id,
and_(
SupportRoleAssignment.workspace_id.is_(None),
SupportRoleAssignment.role_code.in_(AccessService.GLOBAL_ADMIN_ROLES),
),
),
)
return list(
db.execute(
select(SupportRoleAssignment.role_code).where(*conditions),
).scalars().all(),
)
def is_system_admin(self, db: Session, principal: SsoPrincipal) -> bool:
"""Check only Secretary's internal global role assignments.
The ABC `users.type` claim is intentionally not consulted here. ABC's
users/roles/members tables remain responsible for ABC's own console API.
"""
user = self.ensure_support_user(db, principal)
return bool(
set(self._role_codes(db, user, global_only=True))
& AccessService.GLOBAL_ADMIN_ROLES,
)
def _workspace_role(
self,
db: Session,
user: SupportUser,
workspace_id: int,
) -> str | None:
roles = self._role_codes(db, user, workspace_id)
if not roles:
return None
return max(roles, key=lambda role: self.ROLE_PRIORITY.get(role, 0))
def _apply_role_permissions(
self,
access: UserWorkspaceAccess,
role: str,
) -> UserWorkspaceAccess:
can_read, can_write, can_manage, can_approve = self.ROLE_PERMISSIONS.get(
role,
(False, False, False, False),
)
access.workspace_role = role
access.can_read = can_read
access.can_write = can_write
access.can_manage = can_manage
access.can_approve = can_approve
return access
@staticmethod
def _synthetic_access(
principal: SsoPrincipal,
workspace: Workspace,
role: str,
) -> UserWorkspaceAccess:
access = UserWorkspaceAccess(
user_id=principal.user_id,
tenant_id=principal.tenant_id,
workspace_id=workspace.id,
workspace=workspace,
)
return AccessService()._apply_role_permissions(access, role)
def find_workspace_access(
self,
db: Session,
principal: SsoPrincipal,
workspace_code: str,
default_workspace_code: str | None = None,
) -> UserWorkspaceAccess | None:
user = self.ensure_support_user(db, principal)
workspace = db.execute(
select(Workspace).where(
Workspace.workspace_code == workspace_code,
Workspace.is_active.is_(True),
),
).scalar_one_or_none()
if workspace is None or user.status != "ACTIVE":
return None
global_roles = set(self._role_codes(db, user, global_only=True))
if global_roles & AccessService.GLOBAL_ADMIN_ROLES:
return self._synthetic_access(principal, workspace, "SYSTEM_ADMIN")
role = self._workspace_role(db, user, workspace.id)
if role is None:
# A first-time user can submit feedback in the default workspace
# without creating an END_USER role assignment. Role assignments
# are reserved for explicit workspace administration.
if workspace.workspace_code != (
default_workspace_code or self.default_user_workspace_code(db)
):
return None
role = "END_USER"
access = db.execute(
select(UserWorkspaceAccess).where(
UserWorkspaceAccess.user_id == principal.user_id,
UserWorkspaceAccess.tenant_id == principal.tenant_id,
UserWorkspaceAccess.workspace_id == workspace.id,
),
).scalar_one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=principal.user_id,
tenant_id=principal.tenant_id,
workspace_id=workspace.id,
workspace=workspace,
)
else:
access.workspace = workspace
return self._apply_role_permissions(access, role)
def assert_workspace_access(
self,
db: Session,
principal: SsoPrincipal,
workspace_code: str,
*,
write: bool = False,
) -> UserWorkspaceAccess:
access = self.find_workspace_access(db, principal, workspace_code)
if access is None:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 workspace에 대한 권한이 없습니다.")
if write and not access.can_write:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 workspace에 작성 권한이 없습니다.")
if not write and not access.can_read:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="이 workspace를 조회할 권한이 없습니다.")
return access
def list_accessible_workspaces(
self,
db: Session,
principal: SsoPrincipal,
default_workspace_code: str | None = None,
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
default_workspace_code = (
default_workspace_code or self.default_user_workspace_code(db)
)
workspaces = db.execute(
select(Workspace)
.where(Workspace.is_active.is_(True))
.order_by(Workspace.id.asc()),
).scalars().all()
entries: list[tuple[Workspace, UserWorkspaceAccess]] = []
for workspace in workspaces:
access = self.find_workspace_access(
db,
principal,
workspace.workspace_code,
default_workspace_code=default_workspace_code,
)
if access is not None and (access.can_read or access.can_manage):
entries.append((workspace, access))
return entries
def ensure_default_support_access(
self,
db: Session,
principal: SsoPrincipal,
default_workspace_code: str | None = None,
) -> list[tuple[Workspace, UserWorkspaceAccess]]:
user = self.ensure_support_user(db, principal)
if self.is_system_admin(db, principal):
return self.list_accessible_workspaces(
db,
principal,
default_workspace_code=default_workspace_code,
)
default_workspace_code = (
default_workspace_code or self.default_user_workspace_code(db)
)
if default_workspace_code is None:
return []
workspace = db.execute(
select(Workspace).where(
Workspace.workspace_code == default_workspace_code,
Workspace.is_active.is_(True),
),
).scalar_one_or_none()
if workspace is None:
return []
role = self._workspace_role(db, user, workspace.id) or "END_USER"
access = db.execute(
select(UserWorkspaceAccess).where(
UserWorkspaceAccess.user_id == principal.user_id,
UserWorkspaceAccess.tenant_id == principal.tenant_id,
UserWorkspaceAccess.workspace_id == workspace.id,
),
).scalar_one_or_none()
if access is None:
access = UserWorkspaceAccess(
user_id=principal.user_id,
tenant_id=principal.tenant_id,
workspace_id=workspace.id,
)
db.add(access)
access.workspace = workspace
self._apply_role_permissions(access, role)
db.commit()
db.refresh(access)
access.workspace = workspace
return [(workspace, access)]
@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import Any
from app.core.config import settings
class StorageService:
"""Small S3-compatible adapter used for R2-backed support attachments."""
def __init__(self) -> None:
self._client: Any | None = None
@property
def enabled(self) -> bool:
return settings.is_r2_enabled
def _get_client(self) -> Any:
if not self.enabled or not settings.has_r2_config:
raise RuntimeError("R2 storage is not fully configured.")
if self._client is None:
try:
import boto3
except ImportError as exc:
raise RuntimeError(
"R2 storage requires the boto3 dependency."
) from exc
self._client = boto3.client(
"s3",
endpoint_url=settings.r2_endpoint,
aws_access_key_id=settings.r2_access_key_id,
aws_secret_access_key=settings.r2_secret_access_key,
region_name=settings.r2_region,
)
return self._client
def put_bytes(self, key: str, body: bytes, content_type: str | None) -> None:
self._get_client().put_object(
Bucket=settings.r2_bucket,
Key=key,
Body=body,
ContentType=content_type or "application/octet-stream",
)
def get_bytes(self, key: str, bucket: str | None = None) -> bytes:
response = self._get_client().get_object(
Bucket=bucket or settings.r2_bucket,
Key=key,
)
return response["Body"].read()
def delete(self, key: str, bucket: str | None = None) -> None:
self._get_client().delete_object(
Bucket=bucket or settings.r2_bucket,
Key=key,
)
storage_service = StorageService()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
from __future__ import annotations
import json
from urllib import error, parse, request
from sqlalchemy import select, text
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.models import Workspace, WorkspaceChannelMapping
class WorkspaceMappingService:
def _fetch_remote_mappings(
self,
workspace_codes: list[str],
) -> dict[str, dict[str, object]]:
if not workspace_codes or not settings.master_api_key:
return {}
query = parse.urlencode({"workspaceCodes": ",".join(workspace_codes)})
endpoint = (
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/workspace-mappings"
f"?{query}"
)
req = request.Request(
endpoint,
headers={"x-api-key": settings.master_api_key},
method="GET",
)
try:
with request.urlopen(req, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
return {}
raw_items = payload.get("items") if isinstance(payload, dict) else None
if not isinstance(raw_items, list):
return {}
mappings: dict[str, dict[str, object]] = {}
for item in raw_items:
if not isinstance(item, dict):
continue
workspace_code = item.get("workspace_code")
project_id = item.get("project_id")
channel_id = item.get("channel_id")
if (
not isinstance(workspace_code, str)
or not isinstance(project_id, int)
or not isinstance(channel_id, int)
or project_id <= 0
or channel_id <= 0
):
continue
mappings[workspace_code] = item
return mappings
def _fetch_remote_projects(self) -> list[dict[str, object]] | None:
if not settings.master_api_key:
return None
endpoint = (
f"{settings.abc_api_base_url.rstrip('/')}/api/internal/support/projects"
)
req = request.Request(
endpoint,
headers={"x-api-key": settings.master_api_key},
method="GET",
)
try:
with request.urlopen(req, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError):
return None
raw_items = payload.get("items") if isinstance(payload, dict) else None
if not isinstance(raw_items, list):
return None
projects: list[dict[str, object]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
project_id = item.get("project_id")
project_name = item.get("project_name")
channel_id = item.get("channel_id")
channel_name = item.get("channel_name")
if (
not isinstance(project_id, int)
or project_id <= 0
or not isinstance(project_name, str)
or not project_name.strip()
or len(project_name) > 50
):
continue
projects.append(
{
"project_id": project_id,
"project_name": project_name.strip(),
"channel_id": channel_id if isinstance(channel_id, int) else None,
"channel_name": channel_name if isinstance(channel_name, str) else None,
},
)
return projects
def sync_project_workspaces(self, db: Session) -> list[str] | None:
"""Mirror ABC projects as selectable Secretary workspaces.
Project creation remains owned by ABC. The management/access APIs call
this lightweight synchronization before returning workspace options, so
newly created projects become assignable without hard-coded seed data.
``None`` means the ABC project list could not be fetched. An empty
list is a successful response with no projects.
"""
projects = self._fetch_remote_projects()
if projects is None:
return None
project_codes = list(
dict.fromkeys(str(project["project_name"]) for project in projects),
)
# Keep the Secretary workspace catalog aligned with ABC. Older seed
# rows such as Q&A_Platform must not become the default SSO target
# after the corresponding ABC project no longer exists.
stale_query = db.query(Workspace).filter(
Workspace.workspace_type == "SOFTWARE_APP",
Workspace.is_active.is_(True),
)
if project_codes:
stale_query = stale_query.filter(
~Workspace.workspace_code.in_(project_codes),
)
stale_count = stale_query.update(
{Workspace.is_active: False},
synchronize_session=False,
)
if not projects:
db.commit()
return project_codes
changed = 0
for project in projects:
project_name = str(project["project_name"])
db.execute(
text(
"""
INSERT INTO software_apps
(app_code, app_name, description, is_active)
VALUES (:app_code, :app_name, :description, 1)
ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name),
description = VALUES(description),
is_active = 1
""",
),
{
"app_code": project_name,
"app_name": project_name,
"description": f"{project_name} Q&A",
},
)
software_app_id = db.execute(
text("SELECT id FROM software_apps WHERE app_code = :app_code"),
{"app_code": project_name},
).scalar_one()
statement = mysql_insert(Workspace.__table__).values(
workspace_type="SOFTWARE_APP",
software_app_id=int(software_app_id),
service_type_id=None,
workspace_code=project_name,
workspace_name=project_name,
is_active=True,
)
statement = statement.on_duplicate_key_update(
software_app_id=statement.inserted.software_app_id,
workspace_name=statement.inserted.workspace_name,
is_active=True,
)
db.execute(statement)
workspace = db.execute(
select(Workspace).where(Workspace.workspace_code == project_name),
).scalar_one()
changed += 1
channel_id = project["channel_id"]
if not isinstance(channel_id, int) or channel_id <= 0:
continue
existing = db.execute(
select(WorkspaceChannelMapping)
.where(
WorkspaceChannelMapping.workspace_id == workspace.id,
WorkspaceChannelMapping.is_active.is_(True),
)
.order_by(WorkspaceChannelMapping.id.asc()),
).scalars().first()
if existing is not None:
existing.abc_project_id = int(project["project_id"])
existing.abc_channel_id = str(channel_id)
existing.abc_channel_key = str(project.get("channel_name") or "")
continue
mapping_statement = mysql_insert(WorkspaceChannelMapping.__table__).values(
workspace_id=workspace.id,
abc_project_id=int(project["project_id"]),
abc_channel_id=str(channel_id),
abc_channel_key=str(project.get("channel_name") or ""),
is_active=True,
)
mapping_statement = mapping_statement.on_duplicate_key_update(
abc_project_id=mapping_statement.inserted.abc_project_id,
abc_channel_key=mapping_statement.inserted.abc_channel_key,
is_active=True,
)
db.execute(mapping_statement)
if changed or stale_count:
try:
db.commit()
except IntegrityError:
# Another access request may have synchronized the same
# project concurrently. Its committed row is authoritative.
db.rollback()
return project_codes
def sync_missing_mappings(
self,
db: Session,
workspace_codes: list[str] | None = None,
) -> int:
statement = select(Workspace).where(Workspace.is_active.is_(True))
if workspace_codes:
statement = statement.where(Workspace.workspace_code.in_(workspace_codes))
workspaces = db.execute(statement.order_by(Workspace.id.asc())).scalars().all()
if not workspaces:
return 0
missing_workspaces: list[Workspace] = []
for workspace in workspaces:
mappings = db.execute(
select(WorkspaceChannelMapping)
.where(
WorkspaceChannelMapping.workspace_id == workspace.id,
WorkspaceChannelMapping.is_active.is_(True),
)
.order_by(WorkspaceChannelMapping.id.asc()),
).scalars().all()
has_valid_mapping = any(
mapping.abc_project_id is not None
and int(mapping.abc_project_id) > 0
and str(mapping.abc_channel_id).isdigit()
and int(mapping.abc_channel_id) > 0
for mapping in mappings
)
if not has_valid_mapping:
missing_workspaces.append(workspace)
if not missing_workspaces:
return 0
remote_mappings = self._fetch_remote_mappings(
[workspace.workspace_code for workspace in missing_workspaces],
)
changed = 0
for workspace in missing_workspaces:
remote = remote_mappings.get(workspace.workspace_code)
if remote is None:
continue
project_id = int(remote["project_id"])
channel_id = str(remote["channel_id"])
existing = db.execute(
select(WorkspaceChannelMapping)
.where(
WorkspaceChannelMapping.workspace_id == workspace.id,
WorkspaceChannelMapping.is_active.is_(True),
)
.order_by(WorkspaceChannelMapping.id.asc()),
).scalars().first()
if existing is None:
existing = WorkspaceChannelMapping(
workspace_id=workspace.id,
abc_project_id=project_id,
abc_channel_id=channel_id,
abc_channel_key=str(remote.get("channel_name") or ""),
is_active=True,
)
db.add(existing)
else:
existing.abc_project_id = project_id
existing.abc_channel_id = channel_id
existing.abc_channel_key = str(remote.get("channel_name") or "")
existing.is_active = True
changed += 1
if changed:
try:
db.commit()
except IntegrityError:
# Another request may have synchronized the same workspace
# between our read and insert. Keep the request successful.
db.rollback()
return changed
workspace_mapping_service = WorkspaceMappingService()