fix: 전체 테넌트 ID 전송 수정
Deploy feedback demo / deploy (push) Successful in 44s

This commit is contained in:
root
2026-09-14 13:17:57 +09:00
parent be241d4eea
commit 41cb084479
10 changed files with 130 additions and 14 deletions
@@ -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")
@@ -209,6 +209,7 @@ def get_my_access(
return { return {
"user_id": principal.user_id, "user_id": principal.user_id,
"tenant_id": principal.tenant_id, "tenant_id": principal.tenant_id,
"tenant_ids": principal.tenant_ids,
"email": principal.email, "email": principal.email,
"name": principal.name, "name": principal.name,
"department": principal.department, "department": principal.department,
@@ -276,6 +276,7 @@ async def create_ticket(
# because empty values fail min_length validation first. # because empty values fail min_length validation first.
requester_id=principal.user_id, requester_id=principal.user_id,
requester_tenant_id=principal.tenant_id, requester_tenant_id=principal.tenant_id,
requester_tenant_ids=principal.tenant_ids,
requester_contact=str(form.get("requester_contact") or ""), requester_contact=str(form.get("requester_contact") or ""),
title=str(form.get("title") or ""), title=str(form.get("title") or ""),
description=str(form.get("description") or ""), description=str(form.get("description") or ""),
@@ -288,6 +289,7 @@ async def create_ticket(
request_payload = request_payload.model_copy(update={ request_payload = request_payload.model_copy(update={
"requester_id": principal.user_id, "requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id, "requester_tenant_id": principal.tenant_id,
"requester_tenant_ids": principal.tenant_ids,
"requester_email": principal.email, "requester_email": principal.email,
"requester_name": principal.name, "requester_name": principal.name,
"requester_department": principal.department, "requester_department": principal.department,
@@ -300,6 +302,7 @@ async def create_ticket(
"workspace_code": workspace_code, "workspace_code": workspace_code,
"requester_id": principal.user_id, "requester_id": principal.user_id,
"requester_tenant_id": principal.tenant_id, "requester_tenant_id": principal.tenant_id,
"requester_tenant_ids": principal.tenant_ids,
"requester_email": principal.email, "requester_email": principal.email,
"requester_name": principal.name, "requester_name": principal.name,
"requester_department": principal.department, "requester_department": principal.department,
+1
View File
@@ -144,6 +144,7 @@ class SupportTicket(Base):
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id")) workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"))
requester_id: Mapped[str] = mapped_column(String(100)) requester_id: Mapped[str] = mapped_column(String(100))
requester_tenant_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_contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
requester_email: Mapped[str | None] = mapped_column(String(320), nullable=True) requester_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
requester_name: Mapped[str | None] = mapped_column(String(100), nullable=True) requester_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+2
View File
@@ -7,6 +7,7 @@ class TicketCreateRequest(BaseModel):
workspace_code: str = Field(default="", max_length=50) workspace_code: str = Field(default="", max_length=50)
requester_id: str = Field(..., min_length=1, max_length=100) requester_id: str = Field(..., min_length=1, max_length=100)
requester_tenant_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_contact: str | None = Field(default=None, max_length=100)
requester_email: str | None = Field(default=None, max_length=320) requester_email: str | None = Field(default=None, max_length=320)
requester_name: str | None = Field(default=None, max_length=100) requester_name: str | None = Field(default=None, max_length=100)
@@ -187,6 +188,7 @@ class SupportTicketRecord(BaseModel):
description: str description: str
requester_id: str requester_id: str
requester_tenant_id: str requester_tenant_id: str
requester_tenant_ids: list[str] = Field(default_factory=list)
requester_contact: str requester_contact: str
requester_email: str | None requester_email: str | None
requester_name: str | None requester_name: str | None
@@ -169,6 +169,7 @@ class TicketService:
workspace_id=workspace.id, workspace_id=workspace.id,
requester_id=payload.requester_id, requester_id=payload.requester_id,
requester_tenant_id=payload.requester_tenant_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_contact=payload.requester_contact or "",
requester_email=payload.requester_email, requester_email=payload.requester_email,
requester_name=payload.requester_name, requester_name=payload.requester_name,
@@ -1301,6 +1302,7 @@ class TicketService:
description=ticket.description, description=ticket.description,
requester_id=ticket.requester_id, requester_id=ticket.requester_id,
requester_tenant_id=ticket.requester_tenant_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_contact=ticket.requester_contact or "",
requester_email=requester_email, requester_email=requester_email,
requester_name=requester_name, requester_name=requester_name,
@@ -67,6 +67,30 @@ const getSsoRequesterFields = (claims: Record<string, unknown>) => {
(ancestor) => getStringValue(ancestor.type) === 'COMPANY', (ancestor) => getStringValue(ancestor.type) === 'COMPANY',
); );
const tenantIds = new Set<string>();
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<string, unknown>).forEach((tenant) =>
addTenantId(asRecord(tenant)?.id),
);
}
}
return { return {
requester_id: readClaim('sub', 'user_id', 'id'), requester_id: readClaim('sub', 'user_id', 'id'),
requester_uuid: readClaim('sub', 'user_id', 'id'), requester_uuid: readClaim('sub', 'user_id', 'id'),
@@ -89,6 +113,7 @@ const getSsoRequesterFields = (claims: Record<string, unknown>) => {
'employee_number', 'employee_number',
'employeeNumber', 'employeeNumber',
), ),
requester_tenant_ids: JSON.stringify([...tenantIds]),
}; };
}; };
const MAX_ATTACHMENT_COUNT = 10; const MAX_ATTACHMENT_COUNT = 10;
+50 -13
View File
@@ -46,6 +46,7 @@ interface SsoOrgMember {
grade?: unknown; grade?: unknown;
position?: unknown; position?: unknown;
jobTitle?: unknown; jobTitle?: unknown;
tenant_ids?: string[];
} }
type SsoValueShape = type SsoValueShape =
@@ -220,14 +221,25 @@ const getSsoOrgMember = async (
tree?: { members?: unknown }; tree?: { members?: unknown };
tenants?: unknown; tenants?: unknown;
}; };
const tenantIds = new Set<string>();
const rawTenants = Array.isArray(data.tenants) ?
data.tenants
: data.tenants && typeof data.tenants === 'object' ?
Object.values(data.tenants as Record<string, unknown>)
: [];
const tenantRecords = rawTenants
.map((tenant) => asRecord(tenant))
.filter((tenant): tenant is Record<string, unknown> => 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<string, unknown>[] = []; const members: Record<string, unknown>[] = [];
const treeMembers = Array.isArray(data.tree?.members) ? data.tree.members : []; const treeMembers = Array.isArray(data.tree?.members) ? data.tree.members : [];
const tenantMembers = Array.isArray(data.tenants) ? const tenantMembers = tenantRecords.flatMap((tenant) =>
data.tenants.flatMap((tenant) => { Array.isArray(tenant.members) ? tenant.members : [],
const record = asRecord(tenant); );
return Array.isArray(record?.members) ? record.members : [];
})
: [];
for (const candidate of [...treeMembers, ...tenantMembers]) { for (const candidate of [...treeMembers, ...tenantMembers]) {
const member = asRecord(candidate); const member = asRecord(candidate);
@@ -236,13 +248,14 @@ const getSsoOrgMember = async (
const subject = asString(info.id) ?? asString(info.sub) ?? asString(info.user_id); const subject = asString(info.id) ?? asString(info.sub) ?? asString(info.user_id);
const email = asString(info.email); const email = asString(info.email);
return ( const matchedMember = members.find((member) => {
members.find((member) => {
const memberId = asString(member.id); const memberId = asString(member.id);
const memberEmail = asString(member.email); const memberEmail = asString(member.email);
return (subject && memberId === subject) || (email && memberEmail === 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) => { 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), grade: asString(info.grade) ?? asString(member.grade),
position: asString(info.position) ?? asString(member.position), position: asString(info.position) ?? asString(member.position),
jobTitle: asString(info.jobTitle) ?? asString(member.jobTitle), 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를 확인하세요.', 'SSO 사용자 정보에 sub 또는 tenant_id가 없습니다. SUPPORT_TENANT_ID를 확인하세요.',
); );
} }
const tenantIds = asStringArray(info.tenant_ids ?? info.tenantIds); const tenantIds = new Set(asStringArray(info.tenant_ids ?? info.tenantIds));
if (!tenantIds.includes(tenantId)) tenantIds.unshift(tenantId); 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<string, unknown>)) {
addTenantId(asRecord(tenant)?.id);
}
}
tenantIds.add(tenantId);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return signHs256( return signHs256(
{ {
sub: subject, sub: subject,
sso_sub: subject, sso_sub: subject,
tenant_id: tenantId, tenant_id: tenantId,
tenant_ids: tenantIds, tenant_ids: [...tenantIds],
email: asString(info.email), email: asString(info.email),
name: asString(info.name), name: asString(info.name),
department: asString(info.department), department: asString(info.department),
+1
View File
@@ -24,6 +24,7 @@ const SSO_TOKEN_CLAIMS_COOKIE_COUNT = `${SSO_TOKEN_CLAIMS_COOKIE}_count`;
export interface SupportPrincipal { export interface SupportPrincipal {
user_id: string; user_id: string;
tenant_id: string; tenant_id: string;
tenant_ids?: string[];
email?: string | null; email?: string | null;
name?: string | null; name?: string | null;
department?: string | null; department?: string | null;
+1
View File
@@ -87,6 +87,7 @@ export interface SupportTicketRecord extends SupportFeedbackAutomationMetadata {
description: string; description: string;
requester_id: string; requester_id: string;
requester_tenant_id: string; requester_tenant_id: string;
requester_tenant_ids?: string[];
requester_contact: string; requester_contact: string;
requester_email?: string | null; requester_email?: string | null;
requester_name?: string | null; requester_name?: string | null;