diff --git a/apps/secretary-api/alembic/versions/0017_ticket_requester_tenant_ids.py b/apps/secretary-api/alembic/versions/0017_ticket_requester_tenant_ids.py new file mode 100644 index 0000000..283d98b --- /dev/null +++ b/apps/secretary-api/alembic/versions/0017_ticket_requester_tenant_ids.py @@ -0,0 +1,43 @@ +"""store all SSO tenant memberships on support tickets""" + +from __future__ import annotations + +from alembic import op +from sqlalchemy import inspect +import sqlalchemy as sa + + +revision = "0017_ticket_requester_tenant_ids" +down_revision = "0016_feedback_status_stages" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + columns = { + column["name"] + for column in inspect(bind).get_columns("support_tickets") + } + if "requester_tenant_ids" not in columns: + op.add_column( + "support_tickets", + sa.Column("requester_tenant_ids", sa.JSON(), nullable=True), + ) + op.execute( + """ + UPDATE support_tickets + SET requester_tenant_ids = JSON_ARRAY(requester_tenant_id) + WHERE requester_tenant_ids IS NULL + """ + ) + + +def downgrade() -> None: + bind = op.get_bind() + columns = { + column["name"] + for column in inspect(bind).get_columns("support_tickets") + } + if "requester_tenant_ids" in columns: + op.drop_column("support_tickets", "requester_tenant_ids") diff --git a/apps/secretary-api/app/api/routes/access.py b/apps/secretary-api/app/api/routes/access.py index e8f067e..e11c998 100644 --- a/apps/secretary-api/app/api/routes/access.py +++ b/apps/secretary-api/app/api/routes/access.py @@ -209,6 +209,7 @@ def get_my_access( return { "user_id": principal.user_id, "tenant_id": principal.tenant_id, + "tenant_ids": principal.tenant_ids, "email": principal.email, "name": principal.name, "department": principal.department, diff --git a/apps/secretary-api/app/api/routes/tickets.py b/apps/secretary-api/app/api/routes/tickets.py index 5e8216f..7a94757 100644 --- a/apps/secretary-api/app/api/routes/tickets.py +++ b/apps/secretary-api/app/api/routes/tickets.py @@ -276,6 +276,7 @@ async def create_ticket( # because empty values fail min_length validation first. requester_id=principal.user_id, requester_tenant_id=principal.tenant_id, + requester_tenant_ids=principal.tenant_ids, requester_contact=str(form.get("requester_contact") or ""), title=str(form.get("title") or ""), description=str(form.get("description") or ""), @@ -288,6 +289,7 @@ async def create_ticket( request_payload = request_payload.model_copy(update={ "requester_id": principal.user_id, "requester_tenant_id": principal.tenant_id, + "requester_tenant_ids": principal.tenant_ids, "requester_email": principal.email, "requester_name": principal.name, "requester_department": principal.department, @@ -300,6 +302,7 @@ async def create_ticket( "workspace_code": workspace_code, "requester_id": principal.user_id, "requester_tenant_id": principal.tenant_id, + "requester_tenant_ids": principal.tenant_ids, "requester_email": principal.email, "requester_name": principal.name, "requester_department": principal.department, diff --git a/apps/secretary-api/app/db/models.py b/apps/secretary-api/app/db/models.py index 882dd7a..58bbc12 100644 --- a/apps/secretary-api/app/db/models.py +++ b/apps/secretary-api/app/db/models.py @@ -144,6 +144,7 @@ class SupportTicket(Base): workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id")) requester_id: Mapped[str] = mapped_column(String(100)) requester_tenant_id: Mapped[str] = mapped_column(String(100)) + requester_tenant_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) requester_contact: Mapped[str | None] = mapped_column(String(100), nullable=True) requester_email: Mapped[str | None] = mapped_column(String(320), nullable=True) requester_name: Mapped[str | None] = mapped_column(String(100), nullable=True) @@ -323,4 +324,4 @@ class Attachment(Base): ticket: Mapped[SupportTicket] = relationship(back_populates="attachments") comment: Mapped[TicketComment | None] = relationship(back_populates="attachments") - workspace: Mapped[Workspace] = relationship(back_populates="attachments") \ No newline at end of file + workspace: Mapped[Workspace] = relationship(back_populates="attachments") diff --git a/apps/secretary-api/app/schemas/ticket.py b/apps/secretary-api/app/schemas/ticket.py index d811336..7413995 100644 --- a/apps/secretary-api/app/schemas/ticket.py +++ b/apps/secretary-api/app/schemas/ticket.py @@ -7,6 +7,7 @@ class TicketCreateRequest(BaseModel): workspace_code: str = Field(default="", max_length=50) requester_id: str = Field(..., min_length=1, max_length=100) requester_tenant_id: str = Field(..., min_length=1, max_length=100) + requester_tenant_ids: list[str] = Field(default_factory=list) requester_contact: str | None = Field(default=None, max_length=100) requester_email: str | None = Field(default=None, max_length=320) requester_name: str | None = Field(default=None, max_length=100) @@ -187,6 +188,7 @@ class SupportTicketRecord(BaseModel): description: str requester_id: str requester_tenant_id: str + requester_tenant_ids: list[str] = Field(default_factory=list) requester_contact: str requester_email: str | None requester_name: str | None diff --git a/apps/secretary-api/app/services/ticket_service.py b/apps/secretary-api/app/services/ticket_service.py index 4d05740..892778b 100644 --- a/apps/secretary-api/app/services/ticket_service.py +++ b/apps/secretary-api/app/services/ticket_service.py @@ -169,6 +169,7 @@ class TicketService: workspace_id=workspace.id, requester_id=payload.requester_id, requester_tenant_id=payload.requester_tenant_id, + requester_tenant_ids=payload.requester_tenant_ids or [payload.requester_tenant_id], requester_contact=payload.requester_contact or "", requester_email=payload.requester_email, requester_name=payload.requester_name, @@ -1301,6 +1302,7 @@ class TicketService: description=ticket.description, requester_id=ticket.requester_id, requester_tenant_id=ticket.requester_tenant_id, + requester_tenant_ids=ticket.requester_tenant_ids or [ticket.requester_tenant_id], requester_contact=ticket.requester_contact or "", requester_email=requester_email, requester_name=requester_name, diff --git a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts index d5e5795..8a2bf83 100644 --- a/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts +++ b/apps/web/src/pages/api/support/workspaces/[workspaceCode]/submit.ts @@ -67,6 +67,30 @@ const getSsoRequesterFields = (claims: Record) => { (ancestor) => getStringValue(ancestor.type) === 'COMPANY', ); + const tenantIds = new Set(); + const addTenantId = (value: unknown) => { + if (typeof value === 'string' && value.trim()) tenantIds.add(value.trim()); + }; + for (const source of tokenSources) { + const directTenantIds = source.tenant_ids ?? source.tenantIds; + if (Array.isArray(directTenantIds)) { + directTenantIds.forEach(addTenantId); + } + addTenantId(source.tenant_id); + addTenantId(source.tenantId); + addTenantId(asRecord(source.tenant)?.id); + if (Array.isArray(source.joinedTenants)) { + source.joinedTenants.forEach((tenant) => addTenantId(asRecord(tenant)?.id)); + } + if (Array.isArray(source.tenants)) { + source.tenants.forEach((tenant) => addTenantId(asRecord(tenant)?.id)); + } else if (source.tenants && typeof source.tenants === 'object') { + Object.values(source.tenants as Record).forEach((tenant) => + addTenantId(asRecord(tenant)?.id), + ); + } + } + return { requester_id: readClaim('sub', 'user_id', 'id'), requester_uuid: readClaim('sub', 'user_id', 'id'), @@ -89,6 +113,7 @@ const getSsoRequesterFields = (claims: Record) => { 'employee_number', 'employeeNumber', ), + requester_tenant_ids: JSON.stringify([...tenantIds]), }; }; const MAX_ATTACHMENT_COUNT = 10; diff --git a/apps/web/src/server/local-sso.ts b/apps/web/src/server/local-sso.ts index 4c40f6a..7df0408 100644 --- a/apps/web/src/server/local-sso.ts +++ b/apps/web/src/server/local-sso.ts @@ -46,6 +46,7 @@ interface SsoOrgMember { grade?: unknown; position?: unknown; jobTitle?: unknown; + tenant_ids?: string[]; } type SsoValueShape = @@ -220,14 +221,25 @@ const getSsoOrgMember = async ( tree?: { members?: unknown }; tenants?: unknown; }; + const tenantIds = new Set(); + const rawTenants = Array.isArray(data.tenants) ? + data.tenants + : data.tenants && typeof data.tenants === 'object' ? + Object.values(data.tenants as Record) + : []; + const tenantRecords = rawTenants + .map((tenant) => asRecord(tenant)) + .filter((tenant): tenant is Record => tenant !== null); + for (const tenant of tenantRecords) { + const id = asString(tenant.id) ?? asString(tenant.tenant_id); + if (id) tenantIds.add(id); + } + if (tenantId) tenantIds.add(tenantId); const members: Record[] = []; const treeMembers = Array.isArray(data.tree?.members) ? data.tree.members : []; - const tenantMembers = Array.isArray(data.tenants) ? - data.tenants.flatMap((tenant) => { - const record = asRecord(tenant); - return Array.isArray(record?.members) ? record.members : []; - }) - : []; + const tenantMembers = tenantRecords.flatMap((tenant) => + Array.isArray(tenant.members) ? tenant.members : [], + ); for (const candidate of [...treeMembers, ...tenantMembers]) { const member = asRecord(candidate); @@ -236,13 +248,14 @@ const getSsoOrgMember = async ( const subject = asString(info.id) ?? asString(info.sub) ?? asString(info.user_id); const email = asString(info.email); - return ( - members.find((member) => { + const matchedMember = members.find((member) => { const memberId = asString(member.id); const memberEmail = asString(member.email); return (subject && memberId === subject) || (email && memberEmail === email); - }) ?? null - ); + }); + return matchedMember || tenantIds.size > 0 ? + { ...(matchedMember ?? {}), tenant_ids: [...tenantIds] } + : null; }; const enrichSsoUserInfo = async (accessToken: string, info: OidcUserInfo) => { @@ -258,6 +271,12 @@ const enrichSsoUserInfo = async (accessToken: string, info: OidcUserInfo) => { grade: asString(info.grade) ?? asString(member.grade), position: asString(info.position) ?? asString(member.position), jobTitle: asString(info.jobTitle) ?? asString(member.jobTitle), + tenant_ids: [ + ...new Set([ + ...asStringArray(info.tenant_ids ?? info.tenantIds), + ...(member.tenant_ids ?? []), + ]), + ], }; }; @@ -539,15 +558,33 @@ const createSupportSession = (info: OidcUserInfo) => { 'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.', ); } - const tenantIds = asStringArray(info.tenant_ids ?? info.tenantIds); - if (!tenantIds.includes(tenantId)) tenantIds.unshift(tenantId); + const tenantIds = new Set(asStringArray(info.tenant_ids ?? info.tenantIds)); + const addTenantId = (value: unknown) => { + const id = asString(value); + if (id) tenantIds.add(id); + }; + + addTenantId(info.tenant_id); + addTenantId(info.tenantId); + addTenantId(asRecord(info.tenant)?.id); + for (const tenant of Array.isArray(info.joinedTenants) ? info.joinedTenants : []) { + addTenantId(asRecord(tenant)?.id); + } + if (Array.isArray(info.tenants)) { + for (const tenant of info.tenants) addTenantId(asRecord(tenant)?.id); + } else if (info.tenants && typeof info.tenants === 'object') { + for (const tenant of Object.values(info.tenants as Record)) { + addTenantId(asRecord(tenant)?.id); + } + } + tenantIds.add(tenantId); const now = Math.floor(Date.now() / 1000); return signHs256( { sub: subject, sso_sub: subject, tenant_id: tenantId, - tenant_ids: tenantIds, + tenant_ids: [...tenantIds], email: asString(info.email), name: asString(info.name), department: asString(info.department), diff --git a/apps/web/src/server/support-auth.ts b/apps/web/src/server/support-auth.ts index 1374b82..cc7ef9d 100644 --- a/apps/web/src/server/support-auth.ts +++ b/apps/web/src/server/support-auth.ts @@ -24,6 +24,7 @@ const SSO_TOKEN_CLAIMS_COOKIE_COUNT = `${SSO_TOKEN_CLAIMS_COOKIE}_count`; export interface SupportPrincipal { user_id: string; tenant_id: string; + tenant_ids?: string[]; email?: string | null; name?: string | null; department?: string | null; diff --git a/apps/web/src/server/support-types.ts b/apps/web/src/server/support-types.ts index 3cf50c4..7a6ae8f 100644 --- a/apps/web/src/server/support-types.ts +++ b/apps/web/src/server/support-types.ts @@ -87,6 +87,7 @@ export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata { description: string; requester_id: string; requester_tenant_id: string; + requester_tenant_ids?: string[]; requester_contact: string; requester_email?: string | null; requester_name?: string | null;