1
0
forked from baron/baron-sso

10 Commits

41 changed files with 1553 additions and 242 deletions

View File

@@ -91,6 +91,7 @@ jobs:
with: with:
context: ./backend context: ./backend
file: ./backend/Dockerfile file: ./backend/Dockerfile
target: production
load: true load: true
tags: baron_sso/backend:${{ steps.version.outputs.image_tag }} tags: baron_sso/backend:${{ steps.version.outputs.image_tag }}
provenance: false provenance: false
@@ -154,6 +155,19 @@ jobs:
provenance: false provenance: false
sbom: false sbom: false
- name: Verify built Docker images before WORKS upload
env:
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
run: |
set -euo pipefail
for image in backend userfront adminfront devfront orgfront; do
image_ref="baron_sso/${image}:${IMAGE_TAG}"
echo "Checking built Docker image before WORKS upload: ${image_ref}"
docker image inspect "${image_ref}" >/dev/null
docker image ls "${image_ref}"
done
- name: Resolve WORKS Drive access token - name: Resolve WORKS Drive access token
env: env:
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }} WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
@@ -162,7 +176,7 @@ jobs:
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }} WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }} WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }} WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }} WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -176,7 +190,7 @@ jobs:
elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]; then elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]; then
access_token="$(sh -c "${WORKS_DRIVE_ACCESS_TOKEN_CMD}")" access_token="$(sh -c "${WORKS_DRIVE_ACCESS_TOKEN_CMD}")"
else else
token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
response="$(curl -sS -w $'\n%{http_code}' -X POST \ response="$(curl -sS -w $'\n%{http_code}' -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \ -H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \ --data-urlencode "grant_type=refresh_token" \
@@ -217,7 +231,7 @@ jobs:
WORKS_DRIVE_TARGET: sharedrive WORKS_DRIVE_TARGET: sharedrive
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }} WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }} WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }} WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -233,12 +247,33 @@ jobs:
fi fi
done done
for image in backend userfront adminfront devfront orgfront; do images="backend userfront adminfront devfront orgfront"
image_total=5
image_index=0
uploaded_images=""
for image in ${images}; do
image_index=$((image_index + 1))
image_ref="baron_sso/${image}:${IMAGE_TAG}" image_ref="baron_sso/${image}:${IMAGE_TAG}"
DOCKER_IMAGE_REF="${image_ref}" \ echo "WORKS image upload ${image_index}/${image_total}: ${image_ref}"
WORKS_DRIVE_DOCKER_IMAGE_DIR="${WORKS_DRIVE_DOCKER_IMAGE_DIR}" \ docker image inspect "${image_ref}" >/dev/null
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}" \ if DOCKER_IMAGE_REF="${image_ref}" \
WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}" \ WORKS_DRIVE_DOCKER_IMAGE_DIR="${WORKS_DRIVE_DOCKER_IMAGE_DIR}" \
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \ WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}" \
scripts/docker-image/upload_works_drive.sh WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}" \
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \
scripts/docker-image/upload_works_drive.sh; then
uploaded_images="${uploaded_images}${uploaded_images:+ }${image_ref}"
echo "WORKS image upload completed: ${image_ref}"
else
upload_status="$?"
echo "::error::WORKS image upload failed at ${image_index}/${image_total}: ${image_ref}"
echo "Already uploaded images: ${uploaded_images:-none}"
exit "${upload_status}"
fi
done
echo "Uploaded WORKS image archives:"
for image_ref in ${uploaded_images}; do
echo " - ${image_ref}"
done done

View File

@@ -88,7 +88,7 @@ jobs:
ORGFRONT_IMAGE_NAME: baron_sso/orgfront ORGFRONT_IMAGE_NAME: baron_sso/orgfront
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }} IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }}
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_SECRET }}
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }} IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }}
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }} IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }}
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }} IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
@@ -109,7 +109,8 @@ jobs:
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }} WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }} WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }} WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }}
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }} WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }} WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }} WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }} WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}

View File

@@ -107,7 +107,8 @@ jobs:
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }} WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }} WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }} WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }}
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }} WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }} WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }} WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }} WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}

View File

@@ -76,6 +76,40 @@ describe("TenantProfilePage initial profile loading", () => {
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀"); expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal"); expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded"); expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
expect(fetchAllTenantsMock).not.toHaveBeenCalled(); expect(fetchAllTenantsMock).toHaveBeenCalled();
});
it("resolves the persisted parent tenant label even when org config already exists", async () => {
fetchAllTenantsMock.mockResolvedValue({
items: [
{
id: "tenant-company",
type: "ORGANIZATION",
name: "인프라솔루션",
slug: "infra-solution",
description: "",
status: "active",
domains: [],
parentId: "tenant-root",
memberCount: 0,
config: {},
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
],
limit: 100,
offset: 0,
total: 1,
});
renderWithProviders(
<Routes>
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
</Routes>,
);
expect(
await screen.findByText(/인프라솔루션 · infra-solution/),
).toBeInTheDocument();
}); });
}); });

View File

@@ -97,7 +97,7 @@ export function TenantProfilePage() {
const parentQuery = useQuery({ const parentQuery = useQuery({
queryKey: ["tenants", "list-all"], queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(), queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data && !hasPersistedOrgConfig, enabled: !!tenantQuery.data,
}); });
const allTenants = parentQuery.data?.items ?? []; const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data const orgConfigCandidate = tenantQuery.data

View File

@@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
baron: true, baron: true,
baronOrg: true, baronOrg: true,
worksmobileId: false, worksmobileId: false,
externalKey: false, externalKey: true,
worksmobileDomain: true, worksmobileDomain: true,
worksmobile: true, worksmobile: true,
worksmobileOrg: true, worksmobileOrg: true,

View File

@@ -29,6 +29,13 @@ import {
DialogTrigger, DialogTrigger,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -47,6 +54,7 @@ import {
fetchMe, fetchMe,
fetchWorksmobileComparison, fetchWorksmobileComparison,
fetchWorksmobileOverview, fetchWorksmobileOverview,
importWorksmobileUsersFromWorks,
retryWorksmobileJob, retryWorksmobileJob,
type WorksmobileComparisonItem, type WorksmobileComparisonItem,
type WorksmobileOutboxItem, type WorksmobileOutboxItem,
@@ -76,7 +84,7 @@ import {
getWorksmobileRowSelectionKey, getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds, getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds, getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedUpdateUserIds, getWorksmobileSelectedImportUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds, getWorksmobileSelectedWorksOnlyOrgUnitIds,
summarizeWorksmobileComparison, summarizeWorksmobileComparison,
type WorksmobileAccountStatusFilter, type WorksmobileAccountStatusFilter,
@@ -272,6 +280,7 @@ export function TenantWorksmobilePage() {
overviewQuery.refetch(); overviewQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("조직 Sync 작업 등록 실패", { toast.error("조직 Sync 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
@@ -285,6 +294,7 @@ export function TenantWorksmobilePage() {
overviewQuery.refetch(); overviewQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("구성원 Sync 작업 등록 실패", { toast.error("구성원 Sync 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
@@ -296,12 +306,15 @@ export function TenantWorksmobilePage() {
resourceKind, resourceKind,
ids, ids,
initialPassword, initialPassword,
initialPasswordUserIds,
}: { }: {
resourceKind: "users" | "groups"; resourceKind: "users" | "groups";
ids: string[]; ids: string[];
initialPassword?: string; initialPassword?: string;
initialPasswordUserIds?: string[];
}) => { }) => {
const trimmedInitialPassword = initialPassword?.trim(); const trimmedInitialPassword = initialPassword?.trim();
const passwordUserIdSet = new Set(initialPasswordUserIds ?? []);
const failures: string[] = []; const failures: string[] = [];
let successCount = 0; let successCount = 0;
for (const id of ids) { for (const id of ids) {
@@ -311,7 +324,7 @@ export function TenantWorksmobilePage() {
tenantId, tenantId,
id, id,
undefined, undefined,
trimmedInitialPassword, passwordUserIdSet.has(id) ? trimmedInitialPassword : undefined,
); );
} else { } else {
await enqueueWorksmobileOrgUnitSync(tenantId, id); await enqueueWorksmobileOrgUnitSync(tenantId, id);
@@ -355,12 +368,56 @@ export function TenantWorksmobilePage() {
comparisonQuery.refetch(); comparisonQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("WORKS 생성 작업 등록 실패", { toast.error("WORKS 생성 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
}, },
}); });
const importSelectedUsersMutation = useMutation({
mutationFn: async (worksmobileUserIds: string[]) =>
importWorksmobileUsersFromWorks(tenantId, worksmobileUserIds),
onSuccess: (result) => {
setSelectedUserRowKeys([]);
const failureCount = result.failures?.length ?? 0;
const description = [
`Baron 업데이트 ${result.updatedCount}`,
`Baron 생성 ${result.createdCount}`,
`external_key 반영 ${result.externalKeyUpdates}`,
failureCount > 0 ? `실패 ${failureCount}` : "",
]
.filter(Boolean)
.join(", ");
if (failureCount > 0) {
toast.error("일부 Works정보 가져오기 실패", {
description:
result.failures
?.slice(0, 3)
.map((failure) =>
[
failure.email ?? failure.worksmobileId ?? "unknown",
failure.error,
].join(": "),
)
.join("\n") ?? description,
});
} else {
toast.success("Works정보 가져오기를 완료했습니다.", {
description,
});
}
overviewQuery.refetch();
comparisonQuery.refetch();
},
onError: (error) => {
overviewQuery.refetch();
toast.error("Works정보 가져오기 실패", {
description: getErrorMessage(error),
});
},
});
const syncSelectedOrgUnitsMutation = useMutation({ const syncSelectedOrgUnitsMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
baronIds, baronIds,
@@ -389,6 +446,7 @@ export function TenantWorksmobilePage() {
comparisonQuery.refetch(); comparisonQuery.refetch();
}, },
onError: (error) => { onError: (error) => {
overviewQuery.refetch();
toast.error("선택 조직 동기화 작업 등록 실패", { toast.error("선택 조직 동기화 작업 등록 실패", {
description: getErrorMessage(error), description: getErrorMessage(error),
}); });
@@ -561,6 +619,7 @@ export function TenantWorksmobilePage() {
<TableHead></TableHead> <TableHead></TableHead>
<TableHead> </TableHead> <TableHead> </TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead>retry</TableHead> <TableHead>retry</TableHead>
<TableHead className="w-24" /> <TableHead className="w-24" />
</TableRow> </TableRow>
@@ -605,7 +664,29 @@ export function TenantWorksmobilePage() {
</details> </details>
)} )}
</TableCell> </TableCell>
<TableCell>{job.status}</TableCell> <TableCell>
<Badge
variant={
job.status === "failed" ? "destructive" : "outline"
}
>
{job.status}
</Badge>
</TableCell>
<TableCell className="max-w-sm">
{job.lastError ? (
<span
className="line-clamp-3 text-xs text-destructive"
title={job.lastError}
>
{job.lastError}
</span>
) : (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</TableCell>
<TableCell>{job.retryCount}</TableCell> <TableCell>{job.retryCount}</TableCell>
<TableCell> <TableCell>
<Button <Button
@@ -668,21 +749,24 @@ export function TenantWorksmobilePage() {
visibleColumns={userVisibleColumns} visibleColumns={userVisibleColumns}
onVisibleColumnsChange={setUserVisibleColumns} onVisibleColumnsChange={setUserVisibleColumns}
passwordManageTenantId={overview?.config.adminTenantId} passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성" actionLabel="Works에 정보 넣기"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending} actionDisabled={
updateActionLabel="선택 구성원 업데이트 적용" isCreatingUsers ||
onCreateSelected={(ids, initialPassword) => createSelectedMutation.isPending ||
importSelectedUsersMutation.isPending
}
importActionLabel="Works정보 가져오기"
importActionDisabled={importSelectedUsersMutation.isPending}
onCreateSelected={(ids, initialPassword, initialPasswordUserIds) =>
createSelectedMutation.mutateAsync({ createSelectedMutation.mutateAsync({
resourceKind: "users", resourceKind: "users",
ids, ids,
initialPassword, initialPassword,
initialPasswordUserIds,
}) })
} }
onUpdateSelected={(ids) => onImportSelected={(ids) =>
createSelectedMutation.mutate({ importSelectedUsersMutation.mutate(ids)
resourceKind: "users",
ids,
})
} }
requireInitialPassword requireInitialPassword
/> />
@@ -1015,10 +1099,11 @@ function ComparisonTable({
showBaronIdColumn = true, showBaronIdColumn = true,
showManageColumn = true, showManageColumn = true,
actionLabel, actionLabel,
updateActionLabel, importActionLabel,
importActionDisabled = false,
actionDisabled, actionDisabled,
onCreateSelected, onCreateSelected,
onUpdateSelected, onImportSelected,
onRunSelected, onRunSelected,
deleteActionLabel, deleteActionLabel,
deleteActionDisabled = false, deleteActionDisabled = false,
@@ -1051,10 +1136,15 @@ function ComparisonTable({
showBaronIdColumn?: boolean; showBaronIdColumn?: boolean;
showManageColumn?: boolean; showManageColumn?: boolean;
actionLabel: string; actionLabel: string;
updateActionLabel?: string; importActionLabel?: string;
importActionDisabled?: boolean;
actionDisabled: boolean; actionDisabled: boolean;
onCreateSelected: (ids: string[], initialPassword?: string) => unknown; onCreateSelected: (
onUpdateSelected?: (ids: string[]) => void; ids: string[],
initialPassword?: string,
initialPasswordUserIds?: string[],
) => unknown;
onImportSelected?: (worksmobileUserIds: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void; onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string; deleteActionLabel?: string;
deleteActionDisabled?: boolean; deleteActionDisabled?: boolean;
@@ -1066,6 +1156,7 @@ function ComparisonTable({
const [initialPassword, setInitialPassword] = React.useState(""); const [initialPassword, setInitialPassword] = React.useState("");
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] = const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
React.useState<string[]>([]); React.useState<string[]>([]);
const [pendingActionIds, setPendingActionIds] = React.useState<string[]>([]);
const tableViewportRef = React.useRef<HTMLDivElement>(null); const tableViewportRef = React.useRef<HTMLDivElement>(null);
const selectableKeys = rows const selectableKeys = rows
.filter(canSelectWorksmobileRow) .filter(canSelectWorksmobileRow)
@@ -1076,7 +1167,7 @@ function ComparisonTable({
rows, rows,
selectedKeys, selectedKeys,
); );
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds( const selectedImportUserIds = getWorksmobileSelectedImportUserIds(
rows, rows,
selectedKeys, selectedKeys,
); );
@@ -1090,7 +1181,7 @@ function ComparisonTable({
selectedActionIds.length === 0 && selectedActionIds.length === 0 &&
selectedDeleteIds.length > 0 && selectedDeleteIds.length > 0 &&
canRunDeleteAction; canRunDeleteAction;
const canRunUserUpdateAction = Boolean(onUpdateSelected); const canRunUserImportAction = Boolean(onImportSelected);
const selectedActionLabel = shouldRunDeleteAction const selectedActionLabel = shouldRunDeleteAction
? deleteActionLabel ? deleteActionLabel
: actionLabel; : actionLabel;
@@ -1102,11 +1193,9 @@ function ComparisonTable({
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0 ? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
: shouldRunDeleteAction : shouldRunDeleteAction
? selectedDeleteIds.length === 0 || deleteActionDisabled ? selectedDeleteIds.length === 0 || deleteActionDisabled
: requireInitialPassword : selectedActionIds.length === 0) || actionDisabled;
? selectedCreateUserIds.length === 0 const importActionButtonDisabled =
: selectedActionIds.length === 0) || actionDisabled; selectedImportUserIds.length === 0 || importActionDisabled;
const updateActionDisabled =
selectedUpdateUserIds.length === 0 || actionDisabled;
const allSelectableSelected = const allSelectableSelected =
selectableKeys.length > 0 && selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key)); selectableKeys.every((key) => selectedKeys.includes(key));
@@ -1228,7 +1317,8 @@ function ComparisonTable({
onDeleteSelected(selectedDeleteIds); onDeleteSelected(selectedDeleteIds);
return; return;
} }
if (requireInitialPassword) { if (requireInitialPassword && selectedCreateUserIds.length > 0) {
setPendingActionIds(selectedActionIds);
setPendingInitialPasswordIds(selectedCreateUserIds); setPendingInitialPasswordIds(selectedCreateUserIds);
setInitialPassword(""); setInitialPassword("");
setInitialPasswordOpen(true); setInitialPasswordOpen(true);
@@ -1237,11 +1327,11 @@ function ComparisonTable({
onCreateSelected(selectedActionIds); onCreateSelected(selectedActionIds);
}; };
const runUpdateAction = () => { const runImportAction = () => {
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) { if (!onImportSelected || selectedImportUserIds.length === 0) {
return; return;
} }
onUpdateSelected(selectedUpdateUserIds); onImportSelected(selectedImportUserIds);
}; };
const confirmInitialPassword = async () => { const confirmInitialPassword = async () => {
@@ -1251,13 +1341,18 @@ function ComparisonTable({
return; return;
} }
try { try {
await onCreateSelected(pendingInitialPasswordIds, password); await onCreateSelected(
pendingActionIds,
password,
pendingInitialPasswordIds,
);
} catch { } catch {
return; return;
} }
setInitialPasswordOpen(false); setInitialPasswordOpen(false);
setInitialPassword(""); setInitialPassword("");
setPendingInitialPasswordIds([]); setPendingInitialPasswordIds([]);
setPendingActionIds([]);
}; };
return ( return (
@@ -1300,27 +1395,28 @@ function ComparisonTable({
} }
/> />
{accountStatusFilter && onAccountStatusFilterChange ? ( {accountStatusFilter && onAccountStatusFilterChange ? (
<div <Select
className="flex flex-wrap items-center gap-2" value={accountStatusFilter}
role="tablist" onValueChange={(value) =>
aria-label="WORKS 계정 상태" onAccountStatusFilterChange(
value as WorksmobileAccountStatusFilter,
)
}
> >
{worksmobileAccountStatusFilterOptions.map((option) => ( <SelectTrigger
<Button className="h-9 w-[148px]"
key={option.value} aria-label="WORKS 계정 상태"
type="button" >
role="tab" <SelectValue />
size="sm" </SelectTrigger>
variant={ <SelectContent>
accountStatusFilter === option.value ? "default" : "outline" {worksmobileAccountStatusFilterOptions.map((option) => (
} <SelectItem key={option.value} value={option.value}>
aria-selected={accountStatusFilter === option.value} {option.label}
onClick={() => onAccountStatusFilterChange(option.value)} </SelectItem>
> ))}
{option.label} </SelectContent>
</Button> </Select>
))}
</div>
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end"> <div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end">
@@ -1380,15 +1476,15 @@ function ComparisonTable({
> >
{selectedActionLabel} {selectedActionLabel}
</Button> </Button>
{canRunUserUpdateAction && ( {canRunUserImportAction && (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={runUpdateAction} onClick={runImportAction}
disabled={updateActionDisabled} disabled={importActionButtonDisabled}
> >
{updateActionLabel || "선택 구성원 업데이트 적용"} {importActionLabel || "Works정보 가져오기"}
</Button> </Button>
)} )}
<Dialog <Dialog
@@ -1398,6 +1494,7 @@ function ComparisonTable({
if (!open) { if (!open) {
setInitialPassword(""); setInitialPassword("");
setPendingInitialPasswordIds([]); setPendingInitialPasswordIds([]);
setPendingActionIds([]);
} }
}} }}
> >
@@ -1437,7 +1534,7 @@ function ComparisonTable({
onClick={confirmInitialPassword} onClick={confirmInitialPassword}
disabled={actionDisabled} disabled={actionDisabled}
> >
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC
baron: true, baron: true,
baronOrg: true, baronOrg: true,
worksmobileId: false, worksmobileId: false,
externalKey: false, externalKey: true,
worksmobileDomain: true, worksmobileDomain: true,
worksmobile: true, worksmobile: true,
worksmobileOrg: true, worksmobileOrg: true,
@@ -212,6 +212,24 @@ export function getWorksmobileSelectedUpdateUserIds(
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
} }
export function getWorksmobileSelectedImportUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
(row.status === "needs_update" ||
row.status === "missing_external_key" ||
row.status === "missing_in_baron") &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.worksmobileId)
.filter((id): id is string => Boolean(id));
}
export function formatWorksmobileSelectionFailureDescription( export function formatWorksmobileSelectionFailureDescription(
successCount: number, successCount: number,
failures: string[], failures: string[],

View File

@@ -1005,6 +1005,23 @@ export type WorksmobileComparison = {
groups: WorksmobileComparisonItem[]; groups: WorksmobileComparisonItem[];
}; };
export type WorksmobileImportUsersResult = {
updatedCount: number;
createdCount: number;
externalKeyUpdates: number;
failures?: Array<{
worksmobileId?: string;
email?: string;
error: string;
}>;
items?: Array<{
worksmobileId?: string;
baronId?: string;
email?: string;
action: string;
}>;
};
export async function fetchUsers( export async function fetchUsers(
limit = 50, limit = 50,
offset = 0, offset = 0,
@@ -1194,6 +1211,17 @@ export async function enqueueWorksmobileUserSync(
return data; return data;
} }
export async function importWorksmobileUsersFromWorks(
tenantId: string,
worksmobileUserIds: string[],
) {
const { data } = await apiClient.post<WorksmobileImportUsersResult>(
`/v1/admin/tenants/${tenantId}/worksmobile/users/import-from-works`,
{ worksmobileUserIds },
);
return data;
}
export async function resetWorksmobileUserPassword( export async function resetWorksmobileUserPassword(
tenantId: string, tenantId: string,
userId: string, userId: string,

View File

@@ -356,12 +356,10 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("row", { name: /김누락/ }) .getByRole("row", { name: /김누락/ })
.getByRole("checkbox") .getByRole("checkbox")
.check(); .check();
await page await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click(); await page.getByRole("button", { name: "작업 등록" }).click();
await expect await expect
.poll(() => syncRequests) .poll(() => syncRequests)
.toEqual([ .toEqual([
@@ -591,11 +589,11 @@ test.describe("Worksmobile tenant management", () => {
.check(); .check();
await userComparisonSection await userComparisonSection
.getByRole("button", { name: "선택 구성원 WORKS에 생성" }) .getByRole("button", { name: "Works에 정보 넣기" })
.click(); .click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click(); await page.getByRole("button", { name: "작업 등록" }).click();
await expect await expect
.poll(() => syncRequests) .poll(() => syncRequests)
.toEqual([ .toEqual([
@@ -603,6 +601,12 @@ test.describe("Worksmobile tenant management", () => {
userId: "user-missing", userId: "user-missing",
body: expect.objectContaining({ initialPassword: "InitPass123!" }), body: expect.objectContaining({ initialPassword: "InitPass123!" }),
}, },
{
userId: "user-update",
body: expect.not.objectContaining({
initialPassword: expect.anything(),
}),
},
]); ]);
const updateRowCheckbox = userComparisonSection const updateRowCheckbox = userComparisonSection
@@ -614,8 +618,9 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("checkbox") .getByRole("checkbox")
.check(); .check();
await userComparisonSection await userComparisonSection
.getByRole("button", { name: "선택 구성원 업데이트 적용" }) .getByRole("button", { name: "Works에 정보 넣기" })
.click(); .click();
await expect(page.getByText("WORKS 초기 비밀번호")).not.toBeVisible();
await expect await expect
.poll(() => syncRequests) .poll(() => syncRequests)
.toEqual([ .toEqual([
@@ -629,6 +634,12 @@ test.describe("Worksmobile tenant management", () => {
initialPassword: expect.anything(), initialPassword: expect.anything(),
}), }),
}, },
{
userId: "user-update",
body: expect.not.objectContaining({
initialPassword: expect.anything(),
}),
},
]); ]);
}); });
@@ -734,15 +745,13 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("row", { name: /실패 사용자/ }) .getByRole("row", { name: /실패 사용자/ })
.getByRole("checkbox") .getByRole("checkbox")
.check(); .check();
await page await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click(); await page.getByRole("button", { name: "작업 등록" }).click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click(); await page.getByRole("button", { name: "작업 등록" }).click();
await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible(); await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible();
await expect( await expect(
@@ -917,6 +926,90 @@ test.describe("Worksmobile tenant management", () => {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "Content-Disposition", "Access-Control-Expose-Headers": "Content-Disposition",
}; };
const buildRecentJobs = () => [
...(requests.includes("org-rejected-sync")
? [
{
id: "job-org-rejected",
resourceType: "ORGUNIT",
resourceId: "org-rejected",
action: "UPSERT",
status: "failed",
retryCount: 0,
lastError: "target tenant is excluded from Worksmobile sync",
createdAt: "2026-05-01T00:02:00Z",
updatedAt: "2026-05-01T00:02:00Z",
payload: {
displayName: "제외팀",
matchLocalPart: "excluded-team",
requestSummary: {
orgUnitName: "제외팀",
orgUnitExternalKey: "org-rejected",
tenantSlug: "excluded-team",
},
},
},
]
: []),
{
id: "job-retry",
resourceType: "USER",
resourceId: "user-failed",
action: "sync",
status: "failed",
retryCount: 1,
lastError: "worksmobile api failed status=400 body=invalid org",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
payload: {
loginEmail: "changed-user@example.com",
displayName: "변경 사용자",
primaryLeafOrgName: "인재성장",
requestSummary: {
email: "changed-user@example.com",
displayName: "변경 사용자",
userExternalKey: "user-failed",
},
},
},
{
id: "job-org-auto",
resourceType: "ORGUNIT",
resourceId: "org-auto",
action: "UPSERT",
status: "processed",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "people-growth",
requestSummary: {
orgUnitName: "인재성장",
email: "people-growth@example.com",
orgUnitExternalKey: "org-auto",
parentOrgUnitId: "externalKey:parent-org",
},
},
},
{
id: "job-pending",
resourceType: "ORGUNIT",
resourceId: "org-pending",
action: "UPSERT",
status: "pending",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "halla-site",
requestSummary: {
orgUnitName: "한라 현장",
email: "halla-site@hallasanup.com",
orgUnitExternalKey: "org-pending",
},
},
},
];
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url()); const url = new URL(route.request().url());
@@ -948,65 +1041,7 @@ test.describe("Worksmobile tenant management", () => {
tokenConfigured: true, tokenConfigured: true,
adminTenantId: "works-tenant-1", adminTenantId: "works-tenant-1",
}, },
recentJobs: [ recentJobs: buildRecentJobs(),
{
id: "job-retry",
resourceType: "USER",
resourceId: "user-failed",
action: "sync",
status: "failed",
retryCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
payload: {
loginEmail: "changed-user@example.com",
displayName: "변경 사용자",
primaryLeafOrgName: "인재성장",
requestSummary: {
email: "changed-user@example.com",
displayName: "변경 사용자",
userExternalKey: "user-failed",
},
},
},
{
id: "job-org-auto",
resourceType: "ORGUNIT",
resourceId: "org-auto",
action: "UPSERT",
status: "processed",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "people-growth",
requestSummary: {
orgUnitName: "인재성장",
email: "people-growth@example.com",
orgUnitExternalKey: "org-auto",
parentOrgUnitId: "externalKey:parent-org",
},
},
},
{
id: "job-pending",
resourceType: "ORGUNIT",
resourceId: "org-pending",
action: "UPSERT",
status: "pending",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "halla-site",
requestSummary: {
orgUnitName: "한라 현장",
email: "halla-site@hallasanup.com",
orgUnitExternalKey: "org-pending",
},
},
},
],
}, },
headers, headers,
}); });
@@ -1068,6 +1103,20 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { id: "job-org-sync" }, headers }); return route.fulfill({ json: { id: "job-org-sync" }, headers });
} }
if (
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/orgunits/org-rejected/sync",
) &&
method === "POST"
) {
requests.push("org-rejected-sync");
return route.fulfill({
status: 400,
json: { error: "target tenant is excluded from Worksmobile sync" },
headers,
});
}
if ( if (
url.pathname.endsWith( url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync", "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync",
@@ -1116,6 +1165,9 @@ test.describe("Worksmobile tenant management", () => {
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1"); await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
await page.getByRole("button", { name: "조직 Sync" }).click(); await page.getByRole("button", { name: "조직 Sync" }).click();
await expect.poll(() => requests).toContain("org-sync"); await expect.poll(() => requests).toContain("org-sync");
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-rejected");
await page.getByRole("button", { name: "조직 Sync" }).click();
await expect.poll(() => requests).toContain("org-rejected-sync");
await page.getByRole("tab", { name: "사용자" }).click(); await page.getByRole("tab", { name: "사용자" }).click();
await page.getByPlaceholder("Kratos user UUID").fill("user-1"); await page.getByPlaceholder("Kratos user UUID").fill("user-1");
@@ -1123,6 +1175,10 @@ test.describe("Worksmobile tenant management", () => {
await expect.poll(() => requests).toContain("user-sync"); await expect.poll(() => requests).toContain("user-sync");
await page.getByRole("tab", { name: "이력" }).click(); await page.getByRole("tab", { name: "이력" }).click();
const rejectedOrgRow = page.getByRole("row", { name: /제외팀/ });
await expect(rejectedOrgRow).toContainText(
"target tenant is excluded from Worksmobile sync",
);
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText( await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
"changed-user@example.com", "changed-user@example.com",
); );
@@ -1136,6 +1192,9 @@ test.describe("Worksmobile tenant management", () => {
.first(), .first(),
).toBeVisible(); ).toBeVisible();
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ }); const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
await expect(failedJobRow).toContainText(
"worksmobile api failed status=400 body=invalid org",
);
await failedJobRow.getByText("payload").click(); await failedJobRow.getByText("payload").click();
await expect( await expect(
failedJobRow.getByText('"loginEmail": "changed-user@example.com"'), failedJobRow.getByText('"loginEmail": "changed-user@example.com"'),

9
backend/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.env
.env.*
/.codex
/reports
/tmp
/logs
/server
/main
*.log

View File

@@ -1,21 +1,49 @@
FROM golang:1.26.2-alpine # syntax=docker/dockerfile:1.7
FROM golang:1.26.2-alpine AS base
WORKDIR /app WORKDIR /app
# Install git for go mod download if needed
RUN apk add --no-cache git RUN apk add --no-cache git
# Pre-copy go.mod/sum to cache dependencies
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source FROM base AS dev
COPY . . COPY . .
# Build for production (optional, can just run go run for dev) RUN --mount=type=cache,target=/root/.cache/go-build \
RUN go build -o main ./cmd/server CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /usr/local/bin/baron-backend-dev ./cmd/server
EXPOSE 3000 EXPOSE 3000
# Default command (can be overridden by compose) CMD ["/usr/local/bin/baron-backend-dev"]
CMD ["./main"]
FROM base AS builder
ARG TARGETOS=linux
ARG TARGETARCH=amd64
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/main ./cmd/server && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck
FROM gcr.io/distroless/static-debian13:nonroot AS production
WORKDIR /app
COPY --from=builder --chown=65532:65532 /out/main ./main
COPY --from=builder --chown=65532:65532 /out/healthcheck ./healthcheck
COPY --from=builder --chown=65532:65532 /app/docs ./docs
EXPOSE 3000
USER 65532:65532
ENTRYPOINT ["/app/main"]

View File

@@ -6,6 +6,7 @@ import (
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"context" "context"
"encoding/csv" "encoding/csv"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -59,6 +60,9 @@ type worksmobileSyncConfig struct {
ComparisonOutput string ComparisonOutput string
AlignBaronFromWorksOutput string AlignBaronFromWorksOutput string
AlignBaronFromWorksExclude string AlignBaronFromWorksExclude string
ImportFromWorksEmails string
PatchWorksUserNameEmail string
PatchWorksUserName string
InspectOutput string InspectOutput string
CredentialBatchID string CredentialBatchID string
Process bool Process bool
@@ -202,6 +206,28 @@ func runWorksmobileSync(args []string) error {
return err return err
} }
} }
if config.ImportFromWorksEmails != "" {
kratosAdmin := service.NewKratosAdminService()
syncService.SetIdentityServices(service.NewIdentityWriteService(kratosAdmin, nil), kratosAdmin)
worksmobileUserIDs, err := resolveWorksmobileUserIDsByEmail(ctx, newWorksmobileAdminClient(), config.ImportFromWorksEmails)
if err != nil {
return err
}
result, err := syncService.ImportUsersFromWorks(ctx, root.ID, worksmobileUserIDs)
if err != nil {
return err
}
encoded, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Println(string(encoded))
}
if config.PatchWorksUserNameEmail != "" {
if err := patchWorksmobileUserName(ctx, newWorksmobileAdminClient(), config.PatchWorksUserNameEmail, config.PatchWorksUserName); err != nil {
return err
}
}
if config.Process { if config.Process {
return processWorksmobileOutbox(ctx, db, outboxRepo, config) return processWorksmobileOutbox(ctx, db, outboxRepo, config)
} }
@@ -256,6 +282,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update") fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update")
fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows") fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows")
fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output") fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output")
fs.StringVar(&config.ImportFromWorksEmails, "import-from-works-emails", "", "comma-separated Worksmobile emails to import into Baron and patch Worksmobile externalKey")
fs.StringVar(&config.PatchWorksUserNameEmail, "patch-works-user-name-email", "", "Worksmobile email to patch userName by PATCH-only")
fs.StringVar(&config.PatchWorksUserName, "patch-works-user-name", "", "display name for --patch-works-user-name-email")
fs.StringVar(&config.InspectOutput, "inspect-output", "", "output CSV path for inspect/undelete commands") fs.StringVar(&config.InspectOutput, "inspect-output", "", "output CSV path for inspect/undelete commands")
fs.StringVar(&config.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows") fs.StringVar(&config.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows")
fs.BoolVar(&config.Process, "process", false, "process ready Worksmobile outbox jobs") fs.BoolVar(&config.Process, "process", false, "process ready Worksmobile outbox jobs")
@@ -267,8 +296,11 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return config, err return config, err
} }
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process { if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && config.ImportFromWorksEmails == "" && config.PatchWorksUserNameEmail == "" && !config.Process {
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process") return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, --import-from-works-emails, --patch-works-user-name-email, or --process")
}
if config.PatchWorksUserNameEmail != "" && strings.TrimSpace(config.PatchWorksUserName) == "" {
return config, fmt.Errorf("--patch-works-user-name is required with --patch-works-user-name-email")
} }
if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" { if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password") return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password")
@@ -306,6 +338,119 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
return config, nil return config, nil
} }
func resolveWorksmobileUserIDsByEmail(ctx context.Context, client service.WorksmobileDirectoryClient, rawEmails string) ([]string, error) {
if client == nil {
return nil, errors.New("worksmobile client is not configured")
}
targetEmails := splitCommaSeparatedValues(rawEmails)
if len(targetEmails) == 0 {
return nil, errors.New("--import-from-works-emails requires at least one email")
}
remoteUsers, err := client.ListUsers(ctx)
if err != nil {
return nil, err
}
remoteByEmail := make(map[string]service.WorksmobileRemoteUser, len(remoteUsers))
for _, remote := range remoteUsers {
email := strings.ToLower(strings.TrimSpace(remote.Email))
if email == "" {
continue
}
remoteByEmail[email] = remote
}
userIDs := make([]string, 0, len(targetEmails))
for _, targetEmail := range targetEmails {
remote, ok := remoteByEmail[strings.ToLower(targetEmail)]
if !ok {
return nil, fmt.Errorf("worksmobile user not found by email: %s", targetEmail)
}
if id := strings.TrimSpace(remote.ID); id != "" {
userIDs = append(userIDs, id)
continue
}
return nil, fmt.Errorf("worksmobile user id is empty for email: %s", targetEmail)
}
return userIDs, nil
}
func splitCommaSeparatedValues(raw string) []string {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
key := strings.ToLower(value)
if seen[key] {
continue
}
seen[key] = true
values = append(values, value)
}
return values
}
func patchWorksmobileUserName(ctx context.Context, client service.WorksmobileDirectoryClient, email string, displayName string) error {
if client == nil {
return errors.New("worksmobile client is not configured")
}
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
if email == "" || displayName == "" {
return errors.New("email and display name are required")
}
remoteUsers, err := client.ListUsers(ctx)
if err != nil {
return err
}
var target *service.WorksmobileRemoteUser
for i := range remoteUsers {
if strings.EqualFold(strings.TrimSpace(remoteUsers[i].Email), email) {
target = &remoteUsers[i]
break
}
}
if target == nil {
return fmt.Errorf("worksmobile user not found by email: %s", email)
}
if err := client.UpdateUserOnly(ctx, service.WorksmobileUserPayload{
DomainID: target.DomainID,
Email: strings.TrimSpace(target.Email),
UserExternalKey: strings.TrimSpace(target.ExternalID),
UserName: adminctlWorksmobileUserNameFromDisplayName(displayName),
CellPhone: strings.TrimSpace(target.CellPhone),
EmployeeNumber: strings.TrimSpace(target.EmployeeNumber),
Locale: "ko_KR",
Task: strings.TrimSpace(target.Task),
}); err != nil {
return err
}
fmt.Printf("worksmobile user name patched: email=%s display_name=%s\n", email, displayName)
return nil
}
func adminctlWorksmobileUserNameFromDisplayName(name string) service.WorksmobileUserName {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return service.WorksmobileUserName{LastName: name}
}
runes := []rune(name)
if len(runes) < 2 || len(runes) > 4 {
return service.WorksmobileUserName{LastName: name}
}
for _, r := range runes {
if r < '가' || r > '힣' {
return service.WorksmobileUserName{LastName: name}
}
}
return service.WorksmobileUserName{
LastName: string(runes[:1]),
FirstName: string(runes[1:]),
}
}
func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) { func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) {
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID) tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID)
if err != nil { if err != nil {

View File

@@ -0,0 +1,72 @@
package main
import (
"bufio"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
url := strings.TrimSpace(os.Getenv("BACKEND_HEALTHCHECK_URL"))
if url == "" {
port := strings.TrimSpace(os.Getenv("BACKEND_PORT"))
if port == "" {
port = strings.TrimSpace(os.Getenv("PORT"))
}
if port == "" {
port = "3000"
}
url = "http://127.0.0.1:" + port + "/health"
}
statusCode, err := checkHTTP(url, 3*time.Second)
if err != nil {
_, _ = os.Stderr.WriteString("healthcheck request failed: " + err.Error() + "\n")
os.Exit(1)
}
if statusCode < 200 || statusCode >= 400 {
_, _ = os.Stderr.WriteString("healthcheck returned HTTP " + strconv.Itoa(statusCode) + "\n")
os.Exit(1)
}
}
func checkHTTP(rawURL string, timeout time.Duration) (int, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return 0, err
}
host := parsed.Host
if !strings.Contains(host, ":") {
host += ":80"
}
path := parsed.RequestURI()
if path == "" {
path = "/"
}
conn, err := net.DialTimeout("tcp", host, timeout)
if err != nil {
return 0, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "GET " + path + " HTTP/1.1\r\nHost: " + parsed.Host + "\r\nConnection: close\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return 0, err
}
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return 0, err
}
parts := strings.Fields(line)
if len(parts) < 2 {
return 0, nil
}
return strconv.Atoi(parts[1])
}

View File

@@ -329,6 +329,7 @@ func main() {
configureWorksmobileClientFromEnv(worksmobileClient) configureWorksmobileClientFromEnv(worksmobileClient)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient) worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileService.SetIdentityMirror(redisService) worksmobileService.SetIdentityMirror(redisService)
worksmobileService.SetIdentityServices(service.NewIdentityWriteService(kratosAdminService, redisService), kratosAdminService)
worksmobileRelayClient := *worksmobileClient worksmobileRelayClient := *worksmobileClient
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute) worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient) worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
@@ -781,6 +782,7 @@ func main() {
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser) admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/users/import-from-works", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ImportUsersFromWorks)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword) admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob) admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs) admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)

View File

@@ -89,6 +89,26 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(job) return c.Status(fiber.StatusAccepted).JSON(job)
} }
func (h *WorksmobileHandler) ImportUsersFromWorks(c *fiber.Ctx) error {
var req struct {
WorksmobileUserIDs []string `json:"worksmobileUserIds"`
}
if len(c.Body()) > 0 {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
result, err := h.Service.ImportUsersFromWorks(
c.Context(),
strings.TrimSpace(c.Params("tenantId")),
req.WorksmobileUserIDs,
)
if err != nil {
return worksmobileGuardError(c, err, "import_users_from_works")
}
return c.JSON(result)
}
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error { func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId")) userID := strings.TrimSpace(c.Params("userId"))
credentialBatchID, err := parseWorksmobileCredentialBatchID(c) credentialBatchID, err := parseWorksmobileCredentialBatchID(c)

View File

@@ -230,6 +230,10 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI
return service.WorksmobileComparison{}, nil return service.WorksmobileComparison{}, nil
} }
func (f *fakeWorksmobileAdminService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (service.WorksmobileImportUsersResult, error) {
return service.WorksmobileImportUsersResult{UpdatedCount: len(worksmobileUserIDs)}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) { func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) {
return service.WorksmobileBackfillDryRun{}, nil return service.WorksmobileBackfillDryRun{}, nil
} }

View File

@@ -12,6 +12,7 @@ import (
type WorksmobileOutboxRepository interface { type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *domain.WorksmobileOutbox) error Create(ctx context.Context, item *domain.WorksmobileOutbox) error
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error)
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error) DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
@@ -59,6 +60,20 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
return rows, err return rows, err
} }
func (r *worksmobileOutboxRepository) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 1000 {
limit = 50
}
query := r.db.WithContext(ctx).Where("payload ->> 'tenantRootId' = ?", tenantRootID)
if len(resourceIDs) > 0 {
query = query.Or("resource_id IN ?", resourceIDs)
}
var rows []domain.WorksmobileOutbox
err := query.Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) { func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "") Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")

View File

@@ -69,6 +69,56 @@ func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID) require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
} }
func TestWorksmobileOutboxRepositoryListRecentByTenantRoot(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
rows := []domain.WorksmobileOutbox{
{
ID: "00000000-0000-0000-0000-000000000151",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-root",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-root-user",
Payload: domain.JSONMap{"tenantRootId": "root-1"},
CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC),
},
{
ID: "00000000-0000-0000-0000-000000000152",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "child-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-root-org-legacy",
Payload: domain.JSONMap{},
CreatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC),
},
{
ID: "00000000-0000-0000-0000-000000000153",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-other",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-other-root",
Payload: domain.JSONMap{"tenantRootId": "root-2"},
CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
},
}
for i := range rows {
require.NoError(t, testDB.Create(&rows[i]).Error)
}
recent, err := repo.ListRecentByTenantRoot(ctx, "root-1", []string{"child-tenant"}, 50)
require.NoError(t, err)
require.Len(t, recent, 2)
require.Equal(t, "00000000-0000-0000-0000-000000000152", recent[0].ID)
require.Equal(t, "00000000-0000-0000-0000-000000000151", recent[1].ID)
}
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) { func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB) repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background() ctx := context.Background()

View File

@@ -1924,6 +1924,20 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) (
return f.recent, nil return f.recent, nil
} }
func (f *fakeWorksmobileOutboxRepo) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
resourceIDSet := map[string]bool{}
for _, id := range resourceIDs {
resourceIDSet[id] = true
}
rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.recent {
if stringValue(row.Payload["tenantRootId"]) == tenantRootID || resourceIDSet[row.ResourceID] {
rows = append(rows, row)
}
}
return rows, nil
}
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) { func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
rows := make([]domain.WorksmobileOutbox, 0) rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.credentialBatchJobs { for _, row := range f.credentialBatchJobs {

View File

@@ -46,7 +46,8 @@ type WorksmobileUserPayload struct {
} }
type WorksmobileUserName struct { type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"` LastName string `json:"lastName,omitempty"`
FirstName string `json:"firstName,omitempty"`
} }
type WorksmobilePasswordConfig struct { type WorksmobilePasswordConfig struct {
@@ -61,6 +62,26 @@ func (c WorksmobilePasswordConfig) IsZero() bool {
c.ChangePasswordAtNextLogin == nil c.ChangePasswordAtNextLogin == nil
} }
func worksmobileUserNameFromDisplayName(name string) WorksmobileUserName {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return WorksmobileUserName{LastName: name}
}
runes := []rune(name)
if len(runes) < 2 || len(runes) > 4 {
return WorksmobileUserName{LastName: name}
}
for _, r := range runes {
if r < '가' || r > '힣' {
return WorksmobileUserName{LastName: name}
}
}
return WorksmobileUserName{
LastName: string(runes[:1]),
FirstName: string(runes[1:]),
}
}
func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) { func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
type payloadJSON struct { type payloadJSON struct {
DomainID int64 `json:"domainId"` DomainID int64 `json:"domainId"`
@@ -299,7 +320,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
DomainID: domainID, DomainID: domainID,
Email: strings.TrimSpace(user.Email), Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID, UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone), CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber, EmployeeNumber: employeeNumber,
Locale: "ko_KR", Locale: "ko_KR",

View File

@@ -34,6 +34,7 @@ type WorksmobileSyncer interface {
type WorksmobileAdminService interface { type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
@@ -68,6 +69,27 @@ type WorksmobilePendingJobDeleteResult struct {
DeletedCount int `json:"deletedCount"` DeletedCount int `json:"deletedCount"`
} }
type WorksmobileImportUsersResult struct {
UpdatedCount int `json:"updatedCount"`
CreatedCount int `json:"createdCount"`
ExternalKeyUpdates int `json:"externalKeyUpdates"`
Failures []WorksmobileImportUsersFailure `json:"failures,omitempty"`
Items []WorksmobileImportUsersResultItem `json:"items,omitempty"`
}
type WorksmobileImportUsersFailure struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
Email string `json:"email,omitempty"`
Error string `json:"error"`
}
type WorksmobileImportUsersResultItem struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
BaronID string `json:"baronId,omitempty"`
Email string `json:"email,omitempty"`
Action string `json:"action"`
}
type WorksmobileInitialPasswordCredential struct { type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -178,6 +200,8 @@ type worksmobileSyncService struct {
outboxRepo repository.WorksmobileOutboxRepository outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror identityMirror WorksmobileIdentityMirror
identityWriter IdentityWriteService
kratos KratosAdminService
} }
type WorksmobileIdentityMirror interface { type WorksmobileIdentityMirror interface {
@@ -201,18 +225,30 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir
s.identityMirror = source s.identityMirror = source
} }
func (s *worksmobileSyncService) SetIdentityServices(writer IdentityWriteService, kratos KratosAdminService) {
if s == nil {
return
}
s.identityWriter = writer
s.kratos = kratos
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) { func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID) root, err := s.hanmacRoot(ctx, tenantID)
if err != nil { if err != nil {
return WorksmobileTenantOverview{}, err return WorksmobileTenantOverview{}, err
} }
jobs, _ := s.outboxRepo.ListRecent(ctx, 50) scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecentByTenantRoot(ctx, root.ID, worksmobileRecentResourceIDs(root.ID, scopeTenants), 50)
jobs = redactWorksmobileOutboxPayloads(jobs) jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{ return WorksmobileTenantOverview{
Tenant: *tenant, Tenant: *root,
Config: WorksmobileConfigSummary{ Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(tenant.Config), Enabled: WorksmobileEnabled(root.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config), DomainMappings: WorksmobileDomainMappings(root.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(), TokenConfigured: worksmobileDirectoryAuthConfigured(),
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")), AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
}, },
@@ -231,6 +267,15 @@ func worksmobileDirectoryAuthConfigured() bool {
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "") strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
} }
func worksmobileRecentResourceIDs(rootID string, tenants []domain.Tenant) []string {
ids := make([]string, 0, len(tenants)+1)
ids = append(ids, rootID)
for _, tenant := range tenants {
ids = append(ids, tenant.ID)
}
return ids
}
func WorksmobileExcluded(config domain.JSONMap) bool { func WorksmobileExcluded(config domain.JSONMap) bool {
rawValue, ok := config[worksmobileExcludedConfigKey] rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok { if !ok {
@@ -403,6 +448,273 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, nil }, nil
} }
func (s *worksmobileSyncService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
if s.client == nil {
return WorksmobileImportUsersResult{}, errors.New("worksmobile client is not configured")
}
if len(worksmobileUserIDs) == 0 {
return WorksmobileImportUsersResult{}, errors.New("worksmobile user ids are required")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
remoteUsers, err := s.client.ListUsers(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteGroups, err := s.client.ListGroups(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteByID := make(map[string]WorksmobileRemoteUser, len(remoteUsers))
for _, remote := range remoteUsers {
if id := strings.TrimSpace(remote.ID); id != "" {
remoteByID[id] = remote
}
}
groupByID := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, group := range remoteGroups {
if id := strings.TrimSpace(group.ID); id != "" {
groupByID[id] = group
}
}
result := WorksmobileImportUsersResult{}
seen := map[string]bool{}
for _, rawID := range worksmobileUserIDs {
worksmobileID := strings.TrimSpace(rawID)
if worksmobileID == "" || seen[worksmobileID] {
continue
}
seen[worksmobileID] = true
remote, ok := remoteByID[worksmobileID]
if !ok {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Error: "worksmobile user not found"})
continue
}
user, created, externalKeyUpdated, err := s.importSingleWorksmobileUser(ctx, root.ID, remote, tenantByID, groupByID)
if err != nil {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Email: remote.Email, Error: err.Error()})
continue
}
action := "updated"
if created {
action = "created"
result.CreatedCount++
} else {
result.UpdatedCount++
}
if externalKeyUpdated {
result.ExternalKeyUpdates++
}
result.Items = append(result.Items, WorksmobileImportUsersResultItem{
WorksmobileID: worksmobileID,
BaronID: user.ID,
Email: user.Email,
Action: action,
})
}
return result, nil
}
func (s *worksmobileSyncService) importSingleWorksmobileUser(ctx context.Context, rootID string, remote WorksmobileRemoteUser, tenantByID map[string]domain.Tenant, groupByID map[string]WorksmobileRemoteGroup) (domain.User, bool, bool, error) {
email := strings.ToLower(strings.TrimSpace(remote.Email))
if email == "" {
return domain.User{}, false, false, errors.New("worksmobile user email is required")
}
tenantID := worksmobileTenantIDForRemoteUser(remote, groupByID)
tenant, ok := tenantByID[tenantID]
if !ok || !isWorksmobileUserScopeTenant(tenant) {
return domain.User{}, false, false, fmt.Errorf("worksmobile primary org is outside import scope: %s", tenantID)
}
var existing *domain.User
if externalKey := strings.TrimSpace(remote.ExternalID); externalKey != "" {
if user, err := s.userRepo.FindByID(ctx, externalKey); err == nil {
existing = user
} else {
return domain.User{}, false, false, fmt.Errorf("worksmobile external key does not match a Baron user: %s", externalKey)
}
} else if user, err := s.userRepo.FindByEmail(ctx, email); err == nil {
existing = user
}
if existing != nil {
user := *existing
applyWorksmobileRemoteToUser(&user, remote, tenant.ID)
if err := s.updateImportedWorksmobileUserIdentity(ctx, user); err != nil {
return domain.User{}, false, false, err
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey := false
if strings.TrimSpace(remote.ExternalID) == "" {
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey = true
}
return user, false, updatedExternalKey, nil
}
if strings.TrimSpace(remote.ExternalID) != "" {
return domain.User{}, false, false, errors.New("creating Baron user from non-empty unmatched worksmobile external key is not supported")
}
if s.kratos == nil {
return domain.User{}, false, false, errors.New("kratos admin service is required")
}
identityID, err := s.kratos.CreateUser(ctx, &domain.BrokerUser{
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
PhoneNumber: strings.TrimSpace(remote.CellPhone),
Attributes: map[string]any{
"tenant_id": tenant.ID,
"role": domain.RoleUser,
"status": domain.UserStatusActive,
"grade": strings.TrimSpace(remote.LevelName),
"jobTitle": strings.TrimSpace(remote.Task),
},
}, GenerateWorksmobileInitialPassword())
if err != nil {
return domain.User{}, false, false, err
}
now := time.Now().UTC()
user := domain.User{
ID: identityID,
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
Phone: strings.TrimSpace(remote.CellPhone),
Role: domain.RoleUser,
Status: domain.UserStatusActive,
TenantID: &tenant.ID,
Grade: strings.TrimSpace(remote.LevelName),
JobTitle: strings.TrimSpace(remote.Task),
Metadata: worksmobileImportedUserMetadata(remote, tenant),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
return user, true, true, nil
}
func (s *worksmobileSyncService) updateImportedWorksmobileUserIdentity(ctx context.Context, user domain.User) error {
if s.identityWriter == nil {
return nil
}
identity, err := s.identityWriter.GetIdentity(ctx, user.ID)
if err != nil {
return err
}
traits := map[string]any{}
for key, value := range identity.Traits {
traits[key] = value
}
traits["email"] = user.Email
traits["name"] = user.Name
if phone := strings.TrimSpace(user.Phone); phone != "" {
traits["phone_number"] = phone
}
traits["tenant_id"] = strings.TrimSpace(stringPtrValue(user.TenantID))
traits["role"] = user.Role
traits["status"] = user.Status
traits["grade"] = user.Grade
traits["jobTitle"] = user.JobTitle
_, err = s.identityWriter.UpdateIdentity(ctx, IdentityUpdateRequest{
IdentityID: user.ID,
Traits: traits,
State: strings.TrimSpace(identity.State),
Reason: "worksmobile_import_from_works",
Source: "admin_worksmobile",
})
return err
}
func (s *worksmobileSyncService) patchWorksmobileUserExternalKey(ctx context.Context, remote WorksmobileRemoteUser, userID string) error {
return s.client.UpdateUserOnly(ctx, WorksmobileUserPayload{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
UserExternalKey: strings.TrimSpace(userID),
CellPhone: strings.TrimSpace(remote.CellPhone),
EmployeeNumber: strings.TrimSpace(remote.EmployeeNumber),
Locale: "ko_KR",
Task: strings.TrimSpace(remote.Task),
})
}
func applyWorksmobileRemoteToUser(user *domain.User, remote WorksmobileRemoteUser, tenantID string) {
now := time.Now().UTC()
user.Email = strings.ToLower(strings.TrimSpace(remote.Email))
user.Name = strings.TrimSpace(remote.DisplayName)
user.Phone = strings.TrimSpace(remote.CellPhone)
user.Role = domain.NormalizeRole(user.Role)
user.Status = domain.UserStatusActive
user.TenantID = &tenantID
user.Grade = strings.TrimSpace(remote.LevelName)
user.JobTitle = strings.TrimSpace(remote.Task)
user.Metadata = mergeWorksmobileImportedUserMetadata(user.Metadata, remote, tenantID)
user.UpdatedAt = now
}
func worksmobileImportedUserMetadata(remote WorksmobileRemoteUser, tenant domain.Tenant) domain.JSONMap {
return mergeWorksmobileImportedUserMetadata(domain.JSONMap{}, remote, tenant.ID)
}
func mergeWorksmobileImportedUserMetadata(metadata domain.JSONMap, remote WorksmobileRemoteUser, tenantID string) domain.JSONMap {
if metadata == nil {
metadata = domain.JSONMap{}
}
if value := strings.TrimSpace(remote.EmployeeNumber); value != "" {
metadata["employeeNumber"] = value
metadata["employee_id"] = value
}
if value := strings.TrimSpace(remote.LevelName); value != "" {
metadata["grade"] = value
}
if value := strings.TrimSpace(remote.PrimaryOrgUnitName); value != "" {
metadata["department"] = value
}
metadata["worksmobileImportedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
metadata["worksmobileId"] = strings.TrimSpace(remote.ID)
metadata["worksmobileDomainId"] = remote.DomainID
metadata["worksmobilePrimaryOrgUnitId"] = strings.TrimSpace(remote.PrimaryOrgUnitID)
metadata["additionalAppointments"] = []domain.JSONMap{{
"tenantId": tenantID,
"isPrimary": true,
"grade": strings.TrimSpace(remote.LevelName),
}}
return metadata
}
func worksmobileTenantIDForRemoteUser(remote WorksmobileRemoteUser, groupByID map[string]WorksmobileRemoteGroup) string {
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
if tenantID, ok := strings.CutPrefix(primaryOrgUnitID, "externalKey:"); ok {
return strings.TrimSpace(tenantID)
}
if group, ok := groupByID[primaryOrgUnitID]; ok {
return strings.TrimSpace(group.ExternalID)
}
return ""
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) { func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil { if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx) status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
@@ -586,8 +898,9 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
Action: domain.WorksmobileActionDryRun, Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID, DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantIds": orgUnitTenantIDs, "tenantRootId": root.ID,
"userCount": len(users), "tenantIds": orgUnitTenantIDs,
"userCount": len(users),
}, },
}) })
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
@@ -604,10 +917,17 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
} }
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant) tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil { if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err return nil, err
} }
if !ok || tenantRoot.ID != root.ID { if !ok || tenantRoot.ID != root.ID {
return nil, errors.New("target orgunit is outside hanmac-family subtree") err := errors.New("target orgunit is outside hanmac-family subtree")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
} }
scopeTenants, err := s.hanmacSubtree(ctx, root.ID) scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil { if err != nil {
@@ -615,10 +935,18 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
} }
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok { if _, ok := tenantByID[tenant.ID]; !ok {
return nil, errors.New("target tenant is excluded from Worksmobile sync") err := errors.New("target tenant is excluded from Worksmobile sync")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
} }
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) { if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
return nil, errors.New("target tenant is not a worksmobile orgunit tenant") err := errors.New("target tenant is not a worksmobile orgunit tenant")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
} }
return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants) return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
} }
@@ -632,6 +960,9 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
0, 0,
) )
if err != nil { if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err return nil, err
} }
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID) payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
@@ -641,6 +972,7 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID, DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"request": payload, "request": payload,
"matchLocalPart": tenant.Slug, "matchLocalPart": tenant.Slug,
}, },
@@ -651,6 +983,36 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
return item, nil return item, nil
} }
func (s *worksmobileSyncService) recordRejectedOrgUnitSync(ctx context.Context, rootID string, tenant domain.Tenant, reason error) error {
if s.outboxRepo == nil {
return nil
}
payload := domain.JSONMap{
"tenantRootId": rootID,
"displayName": strings.TrimSpace(tenant.Name),
"matchLocalPart": strings.TrimSpace(tenant.Slug),
"tenantSlug": strings.TrimSpace(tenant.Slug),
"requestSummary": domain.JSONMap{
"orgUnitName": strings.TrimSpace(tenant.Name),
"orgUnitExternalKey": tenant.ID,
"tenantSlug": strings.TrimSpace(tenant.Slug),
},
}
if tenant.ParentID != nil {
payload["parentTenantId"] = strings.TrimSpace(*tenant.ParentID)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:rejected:" + tenant.ID + ":" + uuid.NewString(),
Payload: payload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) { func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID) root, err := s.hanmacRoot(ctx, tenantID)
if err != nil { if err != nil {
@@ -692,6 +1054,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
Action: domain.WorksmobileActionDelete, Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID, DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"worksmobileId": worksmobileOrgUnitID, "worksmobileId": worksmobileOrgUnitID,
"externalKey": target.ExternalID, "externalKey": target.ExternalID,
"domainId": target.DomainID, "domainId": target.DomainID,
@@ -756,7 +1119,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return nil, err return nil, err
} }
action := WorksmobileUserStatusAction(user.Status) action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert { if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword) payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
} }
item := &domain.WorksmobileOutbox{ item := &domain.WorksmobileOutbox{
@@ -768,7 +1131,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
} }
item.Payload["displayName"] = strings.TrimSpace(user.Name) item.Payload["displayName"] = strings.TrimSpace(user.Name)
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID) item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" { if batchID := strings.TrimSpace(credentialBatchID); batchID != "" && strings.TrimSpace(payload.PasswordConfig.Password) != "" {
item.Payload["credentialBatchId"] = batchID item.Payload["credentialBatchId"] = batchID
item.Payload["credentialOperation"] = "worksmobile_user_sync" item.Payload["credentialOperation"] = "worksmobile_user_sync"
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
@@ -783,7 +1146,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo
payload := WorksmobileUserPayload{ payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email), Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID, UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)}, UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone), CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata), EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR", Locale: "ko_KR",

View File

@@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin) require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
} }
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) { func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant" rootID := "root-tenant"
tenantID := "saman-tenant" tenantID := "saman-tenant"
@@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, item) require.NotNil(t, item)
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"]) initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.NotEmpty(t, initialPassword) require.Empty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload) request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok) require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType) require.Empty(t, request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password) require.Empty(t, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin) require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin) require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"]))
} }
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) { func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -661,6 +661,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed, Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"loginEmail": "changed@example.com", "loginEmail": "changed@example.com",
"displayName": "변경 사용자", "displayName": "변경 사용자",
"primaryLeafOrgName": "인재성장", "primaryLeafOrgName": "인재성장",
@@ -680,6 +681,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert, Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed, Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{ Payload: domain.JSONMap{
"tenantRootId": root.ID,
"matchLocalPart": "people-growth", "matchLocalPart": "people-growth",
"request": WorksmobileOrgUnitPayload{ "request": WorksmobileOrgUnitPayload{
OrgUnitName: "인재성장", OrgUnitName: "인재성장",
@@ -725,6 +727,67 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, orgPayload["requestSummary"]) }, orgPayload["requestSummary"])
} }
func TestWorksmobileSyncServiceOverviewScopesRecentJobsToTenantRoot(t *testing.T) {
rootID := "root-tenant"
childID := "child-org"
otherRootID := "other-root"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
child := domain.Tenant{
ID: childID,
Slug: "structure-planning",
Name: "구조물계획",
Type: domain.TenantTypeUserGroup,
ParentID: &rootID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{
recent: []domain.WorksmobileOutbox{
{
ID: "job-root-user-failed",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Status: domain.WorksmobileOutboxStatusFailed,
Payload: domain.JSONMap{"tenantRootId": rootID},
LastError: "worksmobile api failed",
},
{
ID: "job-child-org-legacy",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: childID,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: "legacy org job without tenantRootId",
},
{
ID: "job-other-root",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-2",
Status: domain.WorksmobileOutboxStatusFailed,
Payload: domain.JSONMap{"tenantRootId": otherRootID},
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, childID: child},
list: []domain.Tenant{child},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
overview, err := service.GetTenantOverview(context.Background(), rootID)
require.NoError(t, err)
require.Len(t, overview.RecentJobs, 2)
require.Equal(t, "job-root-user-failed", overview.RecentJobs[0].ID)
require.Equal(t, "job-child-org-legacy", overview.RecentJobs[1].ID)
require.Equal(t, "legacy org job without tenantRootId", overview.RecentJobs[1].LastError)
}
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) { func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant" rootID := "root-tenant"
@@ -1041,7 +1104,12 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
require.Nil(t, item) require.Nil(t, item)
require.Error(t, err) require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile orgunit tenant") require.Contains(t, err.Error(), "worksmobile orgunit tenant")
require.Empty(t, outboxRepo.created) require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
require.Equal(t, companyID, outboxRepo.created[0].ResourceID)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, "target tenant is not a worksmobile orgunit tenant", outboxRepo.created[0].LastError)
} }
func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) { func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
@@ -2046,7 +2114,13 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
require.Nil(t, item) require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync") require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Empty(t, outboxRepo.created) require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
require.Equal(t, excludedOrgID, outboxRepo.created[0].ResourceID)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, "excluded-team", outboxRepo.created[0].Payload["matchLocalPart"])
require.Equal(t, "target tenant is excluded from Worksmobile sync", outboxRepo.created[0].LastError)
} }
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) { func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {

View File

@@ -3,6 +3,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: baron_backend container_name: baron_backend
env_file: env_file:
- .env - .env
@@ -42,14 +43,14 @@ services:
- ./backend:/app - ./backend:/app
- ./config:/app/config:ro - ./config:/app/config:ro
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"] command: ["/usr/local/bin/baron-backend-dev"]
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
adminfront: adminfront:
build: build:

View File

@@ -6,7 +6,9 @@ services:
image: postgres:17-alpine image: postgres:17-alpine
container_name: ${COMPOSE_PROJECT_NAME}_db container_name: ${COMPOSE_PROJECT_NAME}_db
environment: environment:
- POSTGRES_USER=${DB_USER:-baron}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-baron_sso}
ports: ports:
- "${DB_PORT}:5432" - "${DB_PORT}:5432"
volumes: volumes:
@@ -282,8 +284,10 @@ services:
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466} - KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467} - KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
- DB_HOST=postgres - DB_HOST=postgres
- DB_PORT=5432
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- CLICKHOUSE_HOST=clickhouse - CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT_NATIVE=9000
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv - SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"
@@ -312,6 +316,8 @@ services:
networks: networks:
- app_net - app_net
- traefik_public - traefik_public
depends_on:
backend: { condition: service_started }
adminfront: adminfront:
image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG} image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG}

View File

@@ -6,7 +6,9 @@ services:
image: postgres:17-alpine image: postgres:17-alpine
container_name: ${COMPOSE_PROJECT_NAME}_db container_name: ${COMPOSE_PROJECT_NAME}_db
environment: environment:
- POSTGRES_USER=${DB_USER:-baron}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-baron_sso}
ports: ports:
- "${DB_PORT}:5432" - "${DB_PORT}:5432"
volumes: volumes:
@@ -282,8 +284,10 @@ services:
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466} - KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467} - KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
- DB_HOST=postgres - DB_HOST=postgres
- DB_PORT=5432
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- CLICKHOUSE_HOST=clickhouse - CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT_NATIVE=9000
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv - SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"
@@ -315,6 +319,8 @@ services:
networks: networks:
- app_net - app_net
- traefik_public - traefik_public
depends_on:
backend: { condition: service_started }
adminfront: adminfront:
build: build:

View File

@@ -3,6 +3,9 @@ events { worker_connections 1024; }
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
types {
application/javascript mjs;
}
# 인스턴스별로 계산된 포트 주입 # 인스턴스별로 계산된 포트 주입
upstream backend_srv { upstream backend_srv {

View File

@@ -3,6 +3,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: baron_backend container_name: baron_backend
env_file: env_file:
- .env - .env
@@ -42,14 +43,14 @@ services:
- ./backend:/app - ./backend:/app
- ./config:/app/config:ro - ./config:/app/config:ro
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"] command: ["/usr/local/bin/baron-backend-dev"]
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
adminfront: adminfront:
build: build:

View File

@@ -26,7 +26,7 @@ services:
- baron_net - baron_net
- ory-net - ory-net
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "/app/healthcheck"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View File

@@ -26,11 +26,11 @@ services:
depends_on: depends_on:
- infra_check - infra_check
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "/app/healthcheck"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
networks: networks:
- baron_net - baron_net

View File

@@ -364,6 +364,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: baron_backend container_name: baron_backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -412,13 +413,13 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"] command: ["/usr/local/bin/baron-backend-dev"]
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 12
start_period: 10s start_period: 60s
adminfront: adminfront:
build: build:

View File

@@ -21,6 +21,8 @@ Gitea Actions의 shared image publish workflow는 `baron_sso/<service>:<image_ta
- 선택 variable `WORKS_DRIVE_DOCKER_IMAGE_DIR=baron-sso` - 선택 variable `WORKS_DRIVE_DOCKER_IMAGE_DIR=baron-sso`
- variable `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID` - variable `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID`
- 선택 variable `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID` - 선택 variable `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID`
- 선택 variable `WORKS_DRIVE_API_BASE_URL`
- 선택 variable `WORKS_DRIVE_OAUTH_TOKEN_URL`
- secret `WORKS_DRIVE_ACCESS_TOKEN`, 또는 variable `WORKS_DRIVE_ACCESS_TOKEN_FILE`, 또는 variable `WORKS_DRIVE_ACCESS_TOKEN_CMD`, 또는 refresh-token 방식의 secret `WORKS_DRIVE_REFRESH_TOKEN` - secret `WORKS_DRIVE_ACCESS_TOKEN`, 또는 variable `WORKS_DRIVE_ACCESS_TOKEN_FILE`, 또는 variable `WORKS_DRIVE_ACCESS_TOKEN_CMD`, 또는 refresh-token 방식의 secret `WORKS_DRIVE_REFRESH_TOKEN`
- refresh-token 방식을 쓸 경우 secret `WORKS_DRIVE_OAUTH_CLIENT_ID`, secret `WORKS_OAUTH_CLIENT_SECRET` - refresh-token 방식을 쓸 경우 secret `WORKS_DRIVE_OAUTH_CLIENT_ID`, secret `WORKS_OAUTH_CLIENT_SECRET`
@@ -44,6 +46,15 @@ Refresh Token Rotation이 켜져 있으면 WORKS가 refresh 응답에 새 Refres
- Rotation을 켠 경우 publish run에서 rotated refresh token 경고가 나오면 `WORKS_DRIVE_REFRESH_TOKEN` secret을 수동 갱신한다. - Rotation을 켠 경우 publish run에서 rotated refresh token 경고가 나오면 `WORKS_DRIVE_REFRESH_TOKEN` secret을 수동 갱신한다.
- secret 자동 갱신이 필요하면 Gitea secret write 전용 token을 별도 설계로 추가한다. - secret 자동 갱신이 필요하면 Gitea secret write 전용 token을 별도 설계로 추가한다.
## 변수 분리 원칙
WORKS Drive archive 접근용 변수와 서비스 런타임용 WORKS Admin 변수는 분리한다.
- archive 업로드/다운로드: `WORKS_DRIVE_API_BASE_URL`, `WORKS_DRIVE_OAUTH_TOKEN_URL`
- backend 런타임 설정: `STG_WORKS_ADMIN_API_BASE_URL`, `STG_WORKS_ADMIN_OAUTH_TOKEN_URL`, `PROD_WORKS_ADMIN_API_BASE_URL`, `PROD_WORKS_ADMIN_OAUTH_TOKEN_URL`
archive script는 호환성을 위해 기존 `WORKS_ADMIN_API_BASE_URL`, `WORKS_ADMIN_OAUTH_TOKEN_URL`도 fallback으로 읽지만, Gitea image publish/deploy workflow에서는 `WORKS_DRIVE_*` 변수를 사용한다.
## 저장 구조 ## 저장 구조
기본 최상위 디렉터리는 다음 환경 변수로 지정한다. 기본 최상위 디렉터리는 다음 환경 변수로 지정한다.
@@ -117,6 +128,8 @@ scripts/docker-image/upload_works_drive.sh
- `WORKS_DRIVE_TARGET=sharedrive` - `WORKS_DRIVE_TARGET=sharedrive`
- `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID` - `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID`
- 선택: `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID` - 선택: `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID`
- 선택: `WORKS_DRIVE_API_BASE_URL`
- 선택: `WORKS_DRIVE_OAUTH_TOKEN_URL`
- `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 또는 서비스 계정 OAuth 변수 - `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 또는 서비스 계정 OAuth 변수
업로드 전 packaging만 확인하려면 다음을 사용한다. 업로드 전 packaging만 확인하려면 다음을 사용한다.

View File

@@ -36,6 +36,7 @@ function renderGuard(initialEntry: string) {
<MemoryRouter initialEntries={[initialEntry]}> <MemoryRouter initialEntries={[initialEntry]}>
<Routes> <Routes>
<Route element={<AuthGuard />}> <Route element={<AuthGuard />}>
<Route path="/chart" element={<div>chart</div>} />
<Route path="/embed/picker" element={<div>picker</div>} /> <Route path="/embed/picker" element={<div>picker</div>} />
</Route> </Route>
<Route path="/login" element={<LocationProbe />} /> <Route path="/login" element={<LocationProbe />} />
@@ -74,4 +75,11 @@ describe("OrgFront AuthGuard auto login redirects", () => {
); );
cleanupRendered(rendered.container, rendered.root); cleanupRendered(rendered.container, rendered.root);
}); });
it("redirects regular app entry to the visible login page", () => {
const rendered = renderGuard("/chart");
expect(rendered.container.textContent).toBe("/login?returnTo=%2Fchart");
cleanupRendered(rendered.container, rendered.root);
});
}); });

View File

@@ -29,9 +29,12 @@ export default function AuthGuard() {
if (!auth.isAuthenticated) { if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}`; const returnTo = `${location.pathname}${location.search}`;
const autoLoginParam = location.pathname.startsWith("/embed/")
? "auto=1&"
: "";
return ( return (
<Navigate <Navigate
to={`/login?auto=1&returnTo=${encodeURIComponent(returnTo)}`} to={`/login?${autoLoginParam}returnTo=${encodeURIComponent(returnTo)}`}
replace replace
/> />
); );

View File

@@ -91,6 +91,8 @@ TZ=Asia/Seoul
SOURCE_ROOT=. SOURCE_ROOT=.
P=${port_prefix} P=${port_prefix}
DB_PORT=${IMAGE_DEPLOY_DB_PORT} DB_PORT=${IMAGE_DEPLOY_DB_PORT}
DB_USER=${DB_USER:-baron}
DB_NAME=${DB_NAME:-baron_sso}
REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT} REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT}
CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP} CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP}
CLICKHOUSE_PORT_NATIVE=${IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE} CLICKHOUSE_PORT_NATIVE=${IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE}

View File

@@ -19,7 +19,41 @@ require_env WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
[[ -f "$IMAGE_DEPLOY_BUNDLE_FILE" ]] || die "bundle file not found: $IMAGE_DEPLOY_BUNDLE_FILE" [[ -f "$IMAGE_DEPLOY_BUNDLE_FILE" ]] || die "bundle file not found: $IMAGE_DEPLOY_BUNDLE_FILE"
log() {
printf '==> %s\n' "$*" >&2
}
refresh_works_drive_access_token() {
[[ -n "${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" ]] || die "WORKS_DRIVE_OAUTH_CLIENT_ID is required when using WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
[[ -n "${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" ]] || die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required when using WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
[[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]] || die "WORKS_DRIVE_OAUTH_REFRESH_TOKEN is required for refresh-token mode."
local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}}"
local response
local access_token
local rotated_refresh_token
log "Refreshing WORKS Drive access token"
response="$(curl -fsS -X POST "$token_url" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=${WORKS_DRIVE_OAUTH_REFRESH_TOKEN}" \
--data-urlencode "client_id=${WORKS_DRIVE_OAUTH_CLIENT_ID}" \
--data-urlencode "client_secret=${WORKS_DRIVE_OAUTH_CLIENT_SECRET}")"
access_token="$(jq -er '.access_token' <<<"$response")"
rotated_refresh_token="$(jq -r '.refresh_token // empty' <<<"$response")"
if [[ -n "$rotated_refresh_token" && "$rotated_refresh_token" != "$WORKS_DRIVE_OAUTH_REFRESH_TOKEN" ]]; then
printf 'WARNING: WORKS returned a rotated refresh token. Update WORKS_DRIVE_REFRESH_TOKEN before the old token ages out.\n' >&2
fi
printf '%s\n' "$access_token"
}
resolve_works_drive_access_token() { resolve_works_drive_access_token() {
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
refresh_works_drive_access_token
return
fi
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then
printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN" printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN"
return return
@@ -41,30 +75,6 @@ resolve_works_drive_access_token() {
return return
fi fi
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
[[ -n "${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" ]] || die "WORKS_DRIVE_OAUTH_CLIENT_ID is required when using WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
[[ -n "${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" ]] || die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required when using WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
local response
local access_token
local rotated_refresh_token
response="$(curl -fsS -X POST "$token_url" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=${WORKS_DRIVE_OAUTH_REFRESH_TOKEN}" \
--data-urlencode "client_id=${WORKS_DRIVE_OAUTH_CLIENT_ID}" \
--data-urlencode "client_secret=${WORKS_DRIVE_OAUTH_CLIENT_SECRET}")"
access_token="$(jq -er '.access_token' <<<"$response")"
rotated_refresh_token="$(jq -r '.refresh_token // empty' <<<"$response")"
if [[ -n "$rotated_refresh_token" && "$rotated_refresh_token" != "$WORKS_DRIVE_OAUTH_REFRESH_TOKEN" ]]; then
printf 'WARNING: WORKS returned a rotated refresh token. Update WORKS_DRIVE_REFRESH_TOKEN before the old token ages out.\n' >&2
fi
printf '%s\n' "$access_token"
return
fi
die "Missing WORKS Drive access auth. Provide WORKS_DRIVE_ACCESS_TOKEN, WORKS_DRIVE_ACCESS_TOKEN_FILE, WORKS_DRIVE_ACCESS_TOKEN_CMD, or WORKS_DRIVE_OAUTH_REFRESH_TOKEN." die "Missing WORKS Drive access auth. Provide WORKS_DRIVE_ACCESS_TOKEN, WORKS_DRIVE_ACCESS_TOKEN_FILE, WORKS_DRIVE_ACCESS_TOKEN_CMD, or WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
} }
@@ -86,7 +96,22 @@ printf '%s\n' "$works_drive_access_token" | ssh "${DEPLOY_USER}@${DEPLOY_HOST}"
export WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID='${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}'; \ export WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID='${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}'; \
export WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID='${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}'; \ export WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID='${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}'; \
export WORKS_DRIVE_DOCKER_IMAGE_DIR='${WORKS_DRIVE_DOCKER_IMAGE_DIR:-baron-sso}'; \ export WORKS_DRIVE_DOCKER_IMAGE_DIR='${WORKS_DRIVE_DOCKER_IMAGE_DIR:-baron-sso}'; \
export WORKS_ADMIN_API_BASE_URL='${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}'; \ export WORKS_DRIVE_API_BASE_URL='${WORKS_DRIVE_API_BASE_URL:-${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}}'; \
echo '==> Validating image deploy compose config'; \
docker compose --env-file .env -f docker-compose.yml config >/dev/null; \
echo '==> Downloading and loading WORKS Drive application images'; \
scripts/docker-image/download_works_drive.sh; \ scripts/docker-image/download_works_drive.sh; \
set -a; \
. ./.env; \
set +a; \
echo '==> Verifying loaded application images'; \
for image_ref in \"\${BACKEND_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${USERFRONT_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${ADMINFRONT_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${DEVFRONT_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${ORGFRONT_IMAGE_NAME}:\${IMAGE_TAG}\"; do \
docker image inspect \"\${image_ref}\" >/dev/null; \
done; \
echo '==> Prefetching runtime images before compose up'; \
docker compose --env-file .env -f docker-compose.yml pull --ignore-pull-failures; \
echo '==> Starting production image stack'; \
docker compose --env-file .env -f docker-compose.yml up -d --remove-orphans; \ docker compose --env-file .env -f docker-compose.yml up -d --remove-orphans; \
echo '==> Refreshing frontend edge containers'; \
docker compose --env-file .env -f docker-compose.yml up -d --force-recreate --no-deps gateway adminfront devfront orgfront; \
docker compose --env-file .env -f docker-compose.yml ps" docker compose --env-file .env -f docker-compose.yml ps"

View File

@@ -136,7 +136,7 @@ load_image_archive() {
image_tag="${IMAGE_TAG:-$(dotenv_value IMAGE_TAG)}" image_tag="${IMAGE_TAG:-$(dotenv_value IMAGE_TAG)}"
drive_id="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID:-${WORKS_DRIVE_SHARED_DRIVE_ID:-}}" drive_id="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID:-${WORKS_DRIVE_SHARED_DRIVE_ID:-}}"
access_token="${WORKS_DRIVE_ACCESS_TOKEN:-}" access_token="${WORKS_DRIVE_ACCESS_TOKEN:-}"
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}" api_base_url="${WORKS_DRIVE_API_BASE_URL:-${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}}"
curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}"
image_root_dir="${WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}}" image_root_dir="${WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}}"
download_root="${WORKS_DOCKER_IMAGE_DOWNLOAD_DIR:-/tmp/baron-sso-docker-image-download}" download_root="${WORKS_DOCKER_IMAGE_DOWNLOAD_DIR:-/tmp/baron-sso-docker-image-download}"

View File

@@ -23,6 +23,8 @@ if [[ -f "$repo_root/.env" ]]; then
WORKS_DRIVE_ACCESS_TOKEN_FILE WORKS_DRIVE_ACCESS_TOKEN_FILE
WORKS_DRIVE_ACCESS_TOKEN_CMD WORKS_DRIVE_ACCESS_TOKEN_CMD
WORKS_DRIVE_OAUTH_SCOPE WORKS_DRIVE_OAUTH_SCOPE
WORKS_DRIVE_API_BASE_URL
WORKS_DRIVE_OAUTH_TOKEN_URL
WORKS_DRIVE_OVERWRITE WORKS_DRIVE_OVERWRITE
WORKS_DRIVE_DRY_RUN WORKS_DRIVE_DRY_RUN
WORKS_DRIVE_CURL_BIN WORKS_DRIVE_CURL_BIN
@@ -72,7 +74,7 @@ folder_cache_file="${WORKS_DOCKER_IMAGE_FOLDER_CACHE_FILE:-${archive_root}/.work
image_root_dir="${WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}}" image_root_dir="${WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}}"
dry_run="${WORKS_DRIVE_DRY_RUN:-false}" dry_run="${WORKS_DRIVE_DRY_RUN:-false}"
target="${WORKS_DRIVE_TARGET:-sharedrive}" target="${WORKS_DRIVE_TARGET:-sharedrive}"
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}" api_base_url="${WORKS_DRIVE_API_BASE_URL:-${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}}"
curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}"
overwrite="${WORKS_DRIVE_OVERWRITE:-true}" overwrite="${WORKS_DRIVE_OVERWRITE:-true}"
upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}" upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}"
@@ -266,7 +268,7 @@ build_jwt_assertion() {
request_service_account_token() { request_service_account_token() {
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}"
local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}}"
local assertion local assertion
local response local response
local response_body local response_body
@@ -296,7 +298,7 @@ request_refresh_access_token() {
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}"
local refresh_token="${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" local refresh_token="${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}"
local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}}"
local response local response
local response_body local response_body
local http_status local http_status
@@ -365,6 +367,27 @@ list_child_folders() {
printf '%s\n' "$response_body" printf '%s\n' "$response_body"
} }
find_folder_id_in_listing() {
local listing_json="$1"
local folder_name="$2"
local strict_type="${3:-true}"
jq -er --arg name "$folder_name" --arg strictType "$strict_type" '
[
(.files // .children // .items // .data // .contents // [])[]
| select((.fileName // .name // .displayName // .title) == $name)
| select(
$strictType != "true"
or (
((.fileType // .type // .resourceType // "") | ascii_downcase) as $type
| ($type == "" or $type == "folder" or $type == "dir" or $type == "directory")
)
)
| .fileId // .id
][0] // empty
' <<<"$listing_json" 2>/dev/null || true
}
create_child_folder() { create_child_folder() {
local access_token="$1" local access_token="$1"
local endpoint="$2" local endpoint="$2"
@@ -382,6 +405,11 @@ create_child_folder() {
"$endpoint")" "$endpoint")"
split_curl_response "$response" response_body http_status split_curl_response "$response" response_body http_status
if [[ "$http_status" -eq 409 ]]; then
printf 'WORKS_CONFLICT\n'
return 2
fi
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
backup_die "WORKS folder create request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" backup_die "WORKS folder create request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
fi fi
@@ -396,35 +424,48 @@ ensure_child_folder() {
local children_endpoint local children_endpoint
local create_folder_endpoint local create_folder_endpoint
local children_json local children_json
local refreshed_children_json
local folder_id local folder_id
local create_status
if [[ -n "$parent_file_id" ]]; then children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")"
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
backup_log "Checking WORKS folder: parent=${parent_file_id:-root} name=$folder_name" >&2
if ! children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
return 1
fi
folder_id="$(find_folder_id_in_listing "$children_json" "$folder_name" "true")"
if [[ -n "$folder_id" ]]; then
backup_log "Found existing WORKS folder: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id"
return
fi
backup_log "Creating WORKS folder: parent=${parent_file_id:-root} name=$folder_name" >&2
if folder_id="$(create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name")"; then
backup_log "Created WORKS folder: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id"
return
else
create_status="$?"
fi
if [[ "$create_status" -eq 2 ]]; then
backup_log "WORKS folder already exists, resolving existing folder id: $folder_name" >&2
children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")" children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")"
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")" if ! refreshed_children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
if ! children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
return 1 return 1
fi fi
folder_id="$(jq -er --arg name "$folder_name" ' folder_id="$(find_folder_id_in_listing "$refreshed_children_json" "$folder_name" "false")"
[
(.files // .children // .items // [])[]
| select((.fileName // .name) == $name)
| select(((.fileType // .type // "") | ascii_downcase) == "folder")
| .fileId // .id
][0] // empty
' <<<"$children_json" 2>/dev/null || true)"
if [[ -n "$folder_id" ]]; then if [[ -n "$folder_id" ]]; then
backup_log "Resolved existing WORKS folder after conflict: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id" printf '%s\n' "$folder_id"
return return
fi fi
else backup_die "WORKS folder already exists but its fileId could not be resolved: $folder_name"
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
fi fi
return 1
if ! folder_id="$(create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name")"; then
return 1
fi
printf '%s\n' "$folder_id"
} }
ensure_folder_path() { ensure_folder_path() {
@@ -441,9 +482,11 @@ ensure_folder_path() {
accumulated_path="${accumulated_path:+$accumulated_path/}$component" accumulated_path="${accumulated_path:+$accumulated_path/}$component"
cached_folder_id="$(read_cached_folder_id "$accumulated_path")" cached_folder_id="$(read_cached_folder_id "$accumulated_path")"
if [[ -n "$cached_folder_id" ]]; then if [[ -n "$cached_folder_id" ]]; then
backup_log "Using cached WORKS folder: $accumulated_path -> $cached_folder_id" >&2
parent_file_id="$cached_folder_id" parent_file_id="$cached_folder_id"
continue continue
fi fi
backup_log "Resolving WORKS folder component: $accumulated_path" >&2
if ! parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")"; then if ! parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")"; then
return 1 return 1
fi fi
@@ -569,7 +612,9 @@ checksum_file="$artifact_dir/${image_name}.${image_tag}.sha256"
manifest_file="$artifact_dir/manifest.${image_tag}.json" manifest_file="$artifact_dir/manifest.${image_tag}.json"
upload_report_file="$artifact_dir/works-upload.json" upload_report_file="$artifact_dir/works-upload.json"
rm -f "$tar_file" "$archive_file" "$checksum_file" "$manifest_file" "$upload_report_file" rm -f "$tar_file" "$archive_file" "$checksum_file" "$upload_report_file"
backup_log "Docker image archive context: image_ref=$image_ref remote_path=$remote_path artifact_dir=$artifact_dir"
if [[ -n "$commit_container" ]]; then if [[ -n "$commit_container" ]]; then
backup_log "Committing container $commit_container to $image_ref" backup_log "Committing container $commit_container to $image_ref"

View File

@@ -38,8 +38,13 @@ if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then
exit 1 exit 1
fi fi
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine AS base$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2 echo "ERROR: backend Dockerfile base stage must use golang:${TARGET_GO_VERSION}-alpine." >&2
exit 1
fi
if ! grep -Eq "^FROM gcr\\.io/distroless/static-debian13:nonroot AS production$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile production stage must use distroless/static-debian13:nonroot." >&2
exit 1 exit 1
fi fi

View File

@@ -44,6 +44,8 @@ grep -Fq "steps.version.outputs.image_tag" "$publish_workflow" \
|| fail "publish workflow must use the computed image tag for built image archives." || fail "publish workflow must use the computed image tag for built image archives."
grep -Fq "Upload built images to WORKS Drive archive" "$publish_workflow" \ grep -Fq "Upload built images to WORKS Drive archive" "$publish_workflow" \
|| fail "publish workflow must archive locally built images to WORKS Drive." || fail "publish workflow must archive locally built images to WORKS Drive."
grep -Fq "Verify built Docker images before WORKS upload" "$publish_workflow" \
|| fail "publish workflow must verify all built Docker images before WORKS upload."
grep -Fq "scripts/docker-image/upload_works_drive.sh" "$publish_workflow" \ grep -Fq "scripts/docker-image/upload_works_drive.sh" "$publish_workflow" \
|| fail "publish workflow must use the shared WORKS Drive image archive script." || fail "publish workflow must use the shared WORKS Drive image archive script."
grep -Fq "docker/build-push-action@v5" "$publish_workflow" \ grep -Fq "docker/build-push-action@v5" "$publish_workflow" \
@@ -54,14 +56,24 @@ for image in backend userfront adminfront devfront orgfront; do
grep -Fq "baron_sso/${image}:" "$publish_workflow" \ grep -Fq "baron_sso/${image}:" "$publish_workflow" \
|| fail "publish workflow must build ${image} image." || fail "publish workflow must build ${image} image."
done done
grep -Fq 'docker image inspect "${image_ref}"' "$publish_workflow" \
|| fail "publish workflow must inspect each built Docker image before upload."
grep -Fq 'WORKS image upload ${image_index}/${image_total}: ${image_ref}' "$publish_workflow" \
|| fail "publish workflow must log each WORKS image upload with index and image ref."
grep -Fq 'uploaded_images' "$publish_workflow" \
|| fail "publish workflow must track successfully uploaded image refs for failure diagnostics."
grep -Fq "WORKS_DRIVE_ACCESS_TOKEN_INPUT: \${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}" "$publish_workflow" \ grep -Fq "WORKS_DRIVE_ACCESS_TOKEN_INPUT: \${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}" "$publish_workflow" \
|| fail "publish workflow must support direct WORKS Drive access token auth." || fail "publish workflow must support direct WORKS Drive access token auth."
grep -Fq "WORKS_DRIVE_OAUTH_CLIENT_SECRET: \${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}" "$publish_workflow" \ grep -Fq "WORKS_DRIVE_OAUTH_CLIENT_SECRET: \${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}" "$publish_workflow" \
|| fail "publish workflow must use the Gitea-compatible WORKS OAuth client secret name." || fail "publish workflow must use the Gitea-compatible WORKS OAuth client secret name."
grep -Fq "WORKS_DRIVE_OAUTH_REFRESH_TOKEN: \${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}" "$publish_workflow" \ grep -Fq "WORKS_DRIVE_OAUTH_REFRESH_TOKEN: \${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}" "$publish_workflow" \
|| fail "publish workflow must support WORKS Drive refresh-token auth." || fail "publish workflow must support WORKS Drive refresh-token auth."
grep -Fq "WORKS_DRIVE_OAUTH_TOKEN_URL: \${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}" "$publish_workflow" \
|| fail "publish workflow must use the WORKS Drive OAuth token URL variable for archive access."
grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$publish_workflow" \ grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$publish_workflow" \
|| fail "publish workflow must use the Docker-image-specific WORKS Drive ID variable." || fail "publish workflow must use the Docker-image-specific WORKS Drive ID variable."
grep -Fq "WORKS_DRIVE_API_BASE_URL: \${{ vars.WORKS_DRIVE_API_BASE_URL }}" "$publish_workflow" \
|| fail "publish workflow must use the WORKS Drive API base URL variable for archive access."
grep -Fq 'WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}"' "$publish_workflow" \ grep -Fq 'WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}"' "$publish_workflow" \
|| fail "publish workflow must map WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID into the shared upload script." || fail "publish workflow must map WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID into the shared upload script."
grep -Fq "Resolve WORKS Drive access token" "$publish_workflow" \ grep -Fq "Resolve WORKS Drive access token" "$publish_workflow" \
@@ -96,6 +108,10 @@ grep -Fq "scripts/deploy/upload_and_run_image_deploy.sh" "$staging_deploy_workfl
|| fail "staging deploy workflow must use the shared remote deploy script." || fail "staging deploy workflow must use the shared remote deploy script."
grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$staging_deploy_workflow" \ grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$staging_deploy_workflow" \
|| fail "staging deploy workflow must pass the Docker-image-specific WORKS Drive ID variable." || fail "staging deploy workflow must pass the Docker-image-specific WORKS Drive ID variable."
grep -Fq "WORKS_DRIVE_API_BASE_URL: \${{ vars.WORKS_DRIVE_API_BASE_URL }}" "$staging_deploy_workflow" \
|| fail "staging deploy workflow must pass the WORKS Drive API base URL into the remote image deploy step."
grep -Fq "WORKS_DRIVE_OAUTH_TOKEN_URL: \${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}" "$staging_deploy_workflow" \
|| fail "staging deploy workflow must pass the WORKS Drive OAuth token URL into the remote image deploy step."
grep -Fq "name: Deploy Baron SSO Production Images" "$deploy_workflow" \ grep -Fq "name: Deploy Baron SSO Production Images" "$deploy_workflow" \
|| fail "deploy workflow must have the expected name." || fail "deploy workflow must have the expected name."
@@ -117,20 +133,34 @@ grep -Fq "scripts/deploy/upload_and_run_image_deploy.sh" "$deploy_workflow" \
|| fail "production deploy workflow must use the shared remote deploy script." || fail "production deploy workflow must use the shared remote deploy script."
grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$deploy_workflow" \ grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$deploy_workflow" \
|| fail "production deploy workflow must pass the Docker-image-specific WORKS Drive ID variable." || fail "production deploy workflow must pass the Docker-image-specific WORKS Drive ID variable."
grep -Fq "WORKS_DRIVE_API_BASE_URL: \${{ vars.WORKS_DRIVE_API_BASE_URL }}" "$deploy_workflow" \
|| fail "production deploy workflow must pass the WORKS Drive API base URL into the remote image deploy step."
grep -Fq "WORKS_DRIVE_OAUTH_TOKEN_URL: \${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}" "$deploy_workflow" \
|| fail "production deploy workflow must pass the WORKS Drive OAuth token URL into the remote image deploy step."
grep -Fq "Same image tag contract as staging" "$deploy_workflow" \ grep -Fq "Same image tag contract as staging" "$deploy_workflow" \
|| fail "production deploy workflow must document that it uses the same image tag as staging." || fail "production deploy workflow must document that it uses the same image tag as staging."
grep -Fq "TRAEFIK_PUBLIC_NETWORK=traefik-public" "$bundle_script" \ grep -Fq "TRAEFIK_PUBLIC_NETWORK=traefik-public" "$bundle_script" \
|| fail "shared bundle script must write Traefik public network env." || fail "shared bundle script must write Traefik public network env."
grep -Fq "scripts/docker-image/download_works_drive.sh" "$remote_deploy_script" \ grep -Fq "scripts/docker-image/download_works_drive.sh" "$remote_deploy_script" \
|| fail "shared remote deploy script must load requested image archives from WORKS Drive before running." || fail "shared remote deploy script must load requested image archives from WORKS Drive before running."
grep -Fq "refresh_works_drive_access_token" "$remote_deploy_script" \
|| fail "shared remote deploy script must refresh WORKS Drive access tokens when a refresh token is available."
grep -Fq 'WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}' "$remote_deploy_script" \
|| fail "shared remote deploy script must prefer WORKS_DRIVE_OAUTH_TOKEN_URL for refresh-token grants."
grep -Fq "docker compose --env-file .env -f docker-compose.yml config" "$remote_deploy_script" \
|| fail "shared remote deploy script must validate the remote compose config before running."
grep -Fq "docker compose --env-file .env -f docker-compose.yml pull --ignore-pull-failures" "$remote_deploy_script" \
|| fail "shared remote deploy script must prefetch runtime images before compose up."
grep -Fq 'docker image inspect \"\${image_ref}\"' "$remote_deploy_script" \
|| fail "shared remote deploy script must inspect loaded application images before compose up."
grep -Fq "docker load" "$works_image_download_script" \ grep -Fq "docker load" "$works_image_download_script" \
|| fail "WORKS Drive image download script must load downloaded archives into Docker." || fail "WORKS Drive image download script must load downloaded archives into Docker."
grep -Fq 'baron-sso/${IMAGE_TAG}/${image}.${IMAGE_TAG}.tar.zst' "$works_image_download_script" \ grep -Fq 'baron-sso/${IMAGE_TAG}/${image}.${IMAGE_TAG}.tar.zst' "$works_image_download_script" \
|| fail "WORKS Drive image download script must document the normalized archive path." || fail "WORKS Drive image download script must document the normalized archive path."
grep -Fq "docker compose --env-file .env -f docker-compose.yml up -d" "$remote_deploy_script" \ grep -Fq "docker compose --env-file .env -f docker-compose.yml up -d" "$remote_deploy_script" \
|| fail "shared remote deploy script must start the stack after pulling images." || fail "shared remote deploy script must start the stack after pulling images."
if grep -Eiq 'harbor|docker login|docker compose --env-file .env -f docker-compose.yml pull|HARBOR_' "$staging_deploy_workflow" "$deploy_workflow" "$remote_deploy_script"; then if grep -Eiq 'harbor|docker login|HARBOR_' "$staging_deploy_workflow" "$deploy_workflow" "$remote_deploy_script"; then
fail "image deploy workflows/scripts must not depend on Harbor registry login or compose pull." fail "image deploy workflows/scripts must not depend on Harbor registry login."
fi fi
if grep -Eq 'docker (build|commit)' "$staging_deploy_workflow" "$deploy_workflow"; then if grep -Eq 'docker (build|commit)' "$staging_deploy_workflow" "$deploy_workflow"; then

View File

@@ -264,9 +264,81 @@ for image in backend userfront; do
"$script" >"$tmp_dir/root-${image}.out" "$script" >"$tmp_dir/root-${image}.out"
done done
root_artifact_dir="$root_archive_dir/baron-sso/v1.2606.ab12"
[[ -f "$root_artifact_dir/backend.v1.2606.ab12.tar.zst" ]] \
|| fail "script must keep the backend image archive after follow-up image uploads."
[[ -f "$root_artifact_dir/userfront.v1.2606.ab12.tar.zst" ]] \
|| fail "script must keep the userfront image archive after follow-up image uploads."
jq -e \
'.images.backend.archive.file_name == "backend.v1.2606.ab12.tar.zst"
and .images.userfront.archive.file_name == "userfront.v1.2606.ab12.tar.zst"' \
"$root_artifact_dir/manifest.v1.2606.ab12.json" >/dev/null \
|| fail "manifest must accumulate all uploaded images for the same tag."
root_create_count="$(cat "${root_curl_log}.root-create-count")" root_create_count="$(cat "${root_curl_log}.root-create-count")"
[[ "$root_create_count" == "1" ]] || fail "script must reuse the cached root archive folder id across image uploads in the same run." [[ "$root_create_count" == "1" ]] || fail "script must reuse the cached root archive folder id across image uploads in the same run."
grep -Fq "sharedrives/root-drive/files/root-tag-id" "$root_curl_log" \ grep -Fq "sharedrives/root-drive/files/root-tag-id" "$root_curl_log" \
|| fail "script must upload follow-up images into the cached tag folder." || fail "script must upload follow-up images into the cached tag folder."
conflict_curl_log="$tmp_dir/conflict-curl.log"
conflict_fake_curl="$tmp_dir/conflict-fake-curl.sh"
cat >"$conflict_fake_curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"${FAKE_CURL_LOG}"
last_arg="${!#}"
case "$last_arg" in
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files)
list_count_file="${FAKE_CURL_LOG}.root-list-count"
list_count=0
[[ -f "$list_count_file" ]] && list_count="$(cat "$list_count_file")"
list_count=$((list_count + 1))
printf '%s' "$list_count" >"$list_count_file"
if [[ "$list_count" -eq 1 ]]; then
printf '{"files":[]}\n200'
else
printf '{"files":[{"fileId":"conflict-baron-sso-id","fileName":"baron-sso","fileType":"FILE"}]}\n200'
fi
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/createfolder)
printf '{"code":"RESOURCE_ALREADY_EXIST","description":"Resource already exists."}\n409'
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/conflict-baron-sso-id/children)
printf '{"files":[]}\n200'
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/conflict-baron-sso-id/createfolder)
printf '{"fileId":"conflict-tag-id","fileName":"v1.2606.ab12","fileType":"FOLDER"}\n200'
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/conflict-tag-id)
printf '{"uploadUrl":"https://upload.example.test/conflict-docker-image"}\n200'
;;
https://upload.example.test/conflict-docker-image)
printf '{"fileId":"uploaded-conflict-file-id"}\n200'
;;
*)
echo "unexpected conflict curl URL: $last_arg" >&2
exit 2
;;
esac
EOF
chmod +x "$conflict_fake_curl"
FAKE_DOCKER_LOG="$docker_log" \
FAKE_CURL_LOG="$conflict_curl_log" \
PATH="$fake_bin:$PATH" \
WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \
WORKS_DRIVE_TARGET="sharedrive" \
WORKS_DRIVE_SHARED_DRIVE_ID="conflict-drive" \
WORKS_DRIVE_PARENT_FILE_ID="" \
WORKS_DRIVE_CURL_BIN="$conflict_fake_curl" \
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$tmp_dir/conflict-archive" \
DOCKER_IMAGE_REF="baron_sso/backend:v1.2606.ab12" \
"$script" >"$tmp_dir/conflict.out" 2>&1
grep -Fq "WORKS folder already exists, resolving existing folder id: baron-sso" "$tmp_dir/conflict.out" \
|| fail "script must recover an existing folder id after WORKS createfolder returns 409."
grep -Fq "sharedrives/conflict-drive/files/conflict-tag-id" "$conflict_curl_log" \
|| fail "script must upload into the resolved folder after a create conflict."
echo "OK: WORKS Drive Docker image archive upload flow commits, packages, and uploads image artifacts" echo "OK: WORKS Drive Docker image archive upload flow commits, packages, and uploads image artifacts"