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:
context: ./backend
file: ./backend/Dockerfile
target: production
load: true
tags: baron_sso/backend:${{ steps.version.outputs.image_tag }}
provenance: false
@@ -154,6 +155,19 @@ jobs:
provenance: 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
env:
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_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
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: |
set -euo pipefail
@@ -176,7 +190,7 @@ jobs:
elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]; then
access_token="$(sh -c "${WORKS_DRIVE_ACCESS_TOKEN_CMD}")"
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 \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
@@ -217,7 +231,7 @@ jobs:
WORKS_DRIVE_TARGET: sharedrive
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_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
run: |
set -euo pipefail
@@ -233,12 +247,33 @@ jobs:
fi
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}"
DOCKER_IMAGE_REF="${image_ref}" \
WORKS_DRIVE_DOCKER_IMAGE_DIR="${WORKS_DRIVE_DOCKER_IMAGE_DIR}" \
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}" \
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
echo "WORKS image upload ${image_index}/${image_total}: ${image_ref}"
docker image inspect "${image_ref}" >/dev/null
if DOCKER_IMAGE_REF="${image_ref}" \
WORKS_DRIVE_DOCKER_IMAGE_DIR="${WORKS_DRIVE_DOCKER_IMAGE_DIR}" \
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}" \
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

View File

@@ -88,7 +88,7 @@ jobs:
ORGFRONT_IMAGE_NAME: baron_sso/orgfront
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_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_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_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_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_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_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
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_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_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_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
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.getByLabelText("공개 범위")).toHaveValue("internal");
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({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
enabled: !!tenantQuery.data,
});
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
externalKey: true,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
@@ -212,6 +212,24 @@ export function getWorksmobileSelectedUpdateUserIds(
.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(
successCount: number,
failures: string[],

View File

@@ -1005,6 +1005,23 @@ export type WorksmobileComparison = {
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(
limit = 50,
offset = 0,
@@ -1194,6 +1211,17 @@ export async function enqueueWorksmobileUserSync(
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(
tenantId: string,
userId: string,

View File

@@ -356,12 +356,10 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("row", { name: /김누락/ })
.getByRole("checkbox")
.check();
await page
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await page.getByRole("button", { name: "작업 등록" }).click();
await expect
.poll(() => syncRequests)
.toEqual([
@@ -591,11 +589,11 @@ test.describe("Worksmobile tenant management", () => {
.check();
await userComparisonSection
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.getByRole("button", { name: "Works에 정보 넣기" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await page.getByRole("button", { name: "작업 등록" }).click();
await expect
.poll(() => syncRequests)
.toEqual([
@@ -603,6 +601,12 @@ test.describe("Worksmobile tenant management", () => {
userId: "user-missing",
body: expect.objectContaining({ initialPassword: "InitPass123!" }),
},
{
userId: "user-update",
body: expect.not.objectContaining({
initialPassword: expect.anything(),
}),
},
]);
const updateRowCheckbox = userComparisonSection
@@ -614,8 +618,9 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("checkbox")
.check();
await userComparisonSection
.getByRole("button", { name: "선택 구성원 업데이트 적용" })
.getByRole("button", { name: "Works에 정보 넣기" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).not.toBeVisible();
await expect
.poll(() => syncRequests)
.toEqual([
@@ -629,6 +634,12 @@ test.describe("Worksmobile tenant management", () => {
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("checkbox")
.check();
await page
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await page.getByRole("button", { name: "작업 등록" }).click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
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(
@@ -917,6 +926,90 @@ test.describe("Worksmobile tenant management", () => {
"Access-Control-Allow-Origin": "*",
"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) => {
const url = new URL(route.request().url());
@@ -948,65 +1041,7 @@ test.describe("Worksmobile tenant management", () => {
tokenConfigured: true,
adminTenantId: "works-tenant-1",
},
recentJobs: [
{
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",
},
},
},
],
recentJobs: buildRecentJobs(),
},
headers,
});
@@ -1068,6 +1103,20 @@ test.describe("Worksmobile tenant management", () => {
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 (
url.pathname.endsWith(
"/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.getByRole("button", { name: "조직 Sync" }).click();
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.getByPlaceholder("Kratos user UUID").fill("user-1");
@@ -1123,6 +1175,10 @@ test.describe("Worksmobile tenant management", () => {
await expect.poll(() => requests).toContain("user-sync");
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(
"changed-user@example.com",
);
@@ -1136,6 +1192,9 @@ test.describe("Worksmobile tenant management", () => {
.first(),
).toBeVisible();
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
await expect(failedJobRow).toContainText(
"worksmobile api failed status=400 body=invalid org",
);
await failedJobRow.getByText("payload").click();
await expect(
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
# Install git for go mod download if needed
RUN apk add --no-cache git
# Pre-copy go.mod/sum to cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source
FROM base AS dev
COPY . .
# Build for production (optional, can just run go run for dev)
RUN go build -o main ./cmd/server
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /usr/local/bin/baron-backend-dev ./cmd/server
EXPOSE 3000
# Default command (can be overridden by compose)
CMD ["./main"]
CMD ["/usr/local/bin/baron-backend-dev"]
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"
"context"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -59,6 +60,9 @@ type worksmobileSyncConfig struct {
ComparisonOutput string
AlignBaronFromWorksOutput string
AlignBaronFromWorksExclude string
ImportFromWorksEmails string
PatchWorksUserNameEmail string
PatchWorksUserName string
InspectOutput string
CredentialBatchID string
Process bool
@@ -202,6 +206,28 @@ func runWorksmobileSync(args []string) error {
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 {
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.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.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.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows")
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 {
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 {
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")
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, --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 == "" {
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
}
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) {
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID)
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)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileService.SetIdentityMirror(redisService)
worksmobileService.SetIdentityServices(service.NewIdentityWriteService(kratosAdminService, redisService), kratosAdminService)
worksmobileRelayClient := *worksmobileClient
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
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/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/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/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)

View File

@@ -89,6 +89,26 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
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 {
userID := strings.TrimSpace(c.Params("userId"))
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)

View File

@@ -230,6 +230,10 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI
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) {
return service.WorksmobileBackfillDryRun{}, nil
}

View File

@@ -12,6 +12,7 @@ import (
type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *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)
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) 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
}
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) {
query := r.db.WithContext(ctx).
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)
}
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) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()

View File

@@ -1924,6 +1924,20 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) (
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) {
rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.credentialBatchJobs {

View File

@@ -46,7 +46,8 @@ type WorksmobileUserPayload struct {
}
type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"`
LastName string `json:"lastName,omitempty"`
FirstName string `json:"firstName,omitempty"`
}
type WorksmobilePasswordConfig struct {
@@ -61,6 +62,26 @@ func (c WorksmobilePasswordConfig) IsZero() bool {
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) {
type payloadJSON struct {
DomainID int64 `json:"domainId"`
@@ -299,7 +320,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
DomainID: domainID,
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",

View File

@@ -34,6 +34,7 @@ type WorksmobileSyncer interface {
type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, 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)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID 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"`
}
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 {
Email string `json:"email"`
Name string `json:"name,omitempty"`
@@ -178,6 +200,8 @@ type worksmobileSyncService struct {
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror
identityWriter IdentityWriteService
kratos KratosAdminService
}
type WorksmobileIdentityMirror interface {
@@ -201,18 +225,30 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir
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) {
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
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)
return WorksmobileTenantOverview{
Tenant: *tenant,
Tenant: *root,
Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(tenant.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config),
Enabled: WorksmobileEnabled(root.Config),
DomainMappings: WorksmobileDomainMappings(root.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(),
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")) != "")
}
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 {
rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok {
@@ -403,6 +448,273 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, 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) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
@@ -586,8 +898,9 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
"tenantRootId": root.ID,
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
},
})
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)
if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
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)
if err != nil {
@@ -615,10 +935,18 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
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) {
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)
}
@@ -632,6 +960,9 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
0,
)
if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
@@ -641,6 +972,7 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"request": payload,
"matchLocalPart": tenant.Slug,
},
@@ -651,6 +983,36 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
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) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -692,6 +1054,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"worksmobileId": worksmobileOrgUnitID,
"externalKey": target.ExternalID,
"domainId": target.DomainID,
@@ -756,7 +1119,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return nil, err
}
action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert {
if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
}
item := &domain.WorksmobileOutbox{
@@ -768,7 +1131,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
}
item.Payload["displayName"] = strings.TrimSpace(user.Name)
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["credentialOperation"] = "worksmobile_user_sync"
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
@@ -783,7 +1146,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo
payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
UserName: worksmobileUserNameFromDisplayName(user.Name),
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR",

View File

@@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
@@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, item)
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.NotEmpty(t, initialPassword)
require.Empty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
require.Empty(t, request.PasswordConfig.PasswordCreationType)
require.Empty(t, request.PasswordConfig.Password)
require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"]))
}
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -661,6 +661,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"loginEmail": "changed@example.com",
"displayName": "변경 사용자",
"primaryLeafOrgName": "인재성장",
@@ -680,6 +681,7 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"matchLocalPart": "people-growth",
"request": WorksmobileOrgUnitPayload{
OrgUnitName: "인재성장",
@@ -725,6 +727,67 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, 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) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
@@ -1041,7 +1104,12 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
require.Nil(t, item)
require.Error(t, err)
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) {
@@ -2046,7 +2114,13 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
require.Nil(t, item)
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -364,6 +364,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
container_name: baron_backend
restart: unless-stopped
env_file:
@@ -412,13 +413,13 @@ services:
volumes:
- ./backend:/app
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["go", "run", "./cmd/server"]
command: ["/usr/local/bin/baron-backend-dev"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
retries: 12
start_period: 60s
adminfront:
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_DRIVE_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`
- 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을 수동 갱신한다.
- 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_DOCKER_IMAGE_DRIVE_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 변수
업로드 전 packaging만 확인하려면 다음을 사용한다.

View File

@@ -36,6 +36,7 @@ function renderGuard(initialEntry: string) {
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route element={<AuthGuard />}>
<Route path="/chart" element={<div>chart</div>} />
<Route path="/embed/picker" element={<div>picker</div>} />
</Route>
<Route path="/login" element={<LocationProbe />} />
@@ -74,4 +75,11 @@ describe("OrgFront AuthGuard auto login redirects", () => {
);
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) {
const returnTo = `${location.pathname}${location.search}`;
const autoLoginParam = location.pathname.startsWith("/embed/")
? "auto=1&"
: "";
return (
<Navigate
to={`/login?auto=1&returnTo=${encodeURIComponent(returnTo)}`}
to={`/login?${autoLoginParam}returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);

View File

@@ -91,6 +91,8 @@ TZ=Asia/Seoul
SOURCE_ROOT=.
P=${port_prefix}
DB_PORT=${IMAGE_DEPLOY_DB_PORT}
DB_USER=${DB_USER:-baron}
DB_NAME=${DB_NAME:-baron_sso}
REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT}
CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP}
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"
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() {
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
refresh_works_drive_access_token
return
fi
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then
printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN"
return
@@ -41,30 +75,6 @@ resolve_works_drive_access_token() {
return
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."
}
@@ -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_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_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; \
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; \
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"

View File

@@ -136,7 +136,7 @@ load_image_archive() {
image_tag="${IMAGE_TAG:-$(dotenv_value IMAGE_TAG)}"
drive_id="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID:-${WORKS_DRIVE_SHARED_DRIVE_ID:-}}"
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}"
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}"

View File

@@ -23,6 +23,8 @@ if [[ -f "$repo_root/.env" ]]; then
WORKS_DRIVE_ACCESS_TOKEN_FILE
WORKS_DRIVE_ACCESS_TOKEN_CMD
WORKS_DRIVE_OAUTH_SCOPE
WORKS_DRIVE_API_BASE_URL
WORKS_DRIVE_OAUTH_TOKEN_URL
WORKS_DRIVE_OVERWRITE
WORKS_DRIVE_DRY_RUN
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}}"
dry_run="${WORKS_DRIVE_DRY_RUN:-false}"
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}"
overwrite="${WORKS_DRIVE_OVERWRITE:-true}"
upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}"
@@ -266,7 +268,7 @@ build_jwt_assertion() {
request_service_account_token() {
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
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 response
local response_body
@@ -296,7 +298,7 @@ request_refresh_access_token() {
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}"
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_body
local http_status
@@ -365,6 +367,27 @@ list_child_folders() {
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() {
local access_token="$1"
local endpoint="$2"
@@ -382,6 +405,11 @@ create_child_folder() {
"$endpoint")"
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
backup_die "WORKS folder create request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
fi
@@ -396,35 +424,48 @@ ensure_child_folder() {
local children_endpoint
local create_folder_endpoint
local children_json
local refreshed_children_json
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")"
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
if ! children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
if ! refreshed_children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
return 1
fi
folder_id="$(jq -er --arg name "$folder_name" '
[
(.files // .children // .items // [])[]
| select((.fileName // .name) == $name)
| select(((.fileType // .type // "") | ascii_downcase) == "folder")
| .fileId // .id
][0] // empty
' <<<"$children_json" 2>/dev/null || true)"
folder_id="$(find_folder_id_in_listing "$refreshed_children_json" "$folder_name" "false")"
if [[ -n "$folder_id" ]]; then
backup_log "Resolved existing WORKS folder after conflict: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id"
return
fi
else
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
backup_die "WORKS folder already exists but its fileId could not be resolved: $folder_name"
fi
if ! folder_id="$(create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name")"; then
return 1
fi
printf '%s\n' "$folder_id"
return 1
}
ensure_folder_path() {
@@ -441,9 +482,11 @@ ensure_folder_path() {
accumulated_path="${accumulated_path:+$accumulated_path/}$component"
cached_folder_id="$(read_cached_folder_id "$accumulated_path")"
if [[ -n "$cached_folder_id" ]]; then
backup_log "Using cached WORKS folder: $accumulated_path -> $cached_folder_id" >&2
parent_file_id="$cached_folder_id"
continue
fi
backup_log "Resolving WORKS folder component: $accumulated_path" >&2
if ! parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")"; then
return 1
fi
@@ -569,7 +612,9 @@ checksum_file="$artifact_dir/${image_name}.${image_tag}.sha256"
manifest_file="$artifact_dir/manifest.${image_tag}.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
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
fi
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine AS base$" "$BACKEND_DOCKERFILE"; then
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
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."
grep -Fq "Upload built images to WORKS Drive archive" "$publish_workflow" \
|| 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" \
|| fail "publish workflow must use the shared WORKS Drive image archive script."
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" \
|| fail "publish workflow must build ${image} image."
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" \
|| 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" \
|| 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" \
|| 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" \
|| 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" \
|| 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" \
@@ -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."
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."
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" \
|| 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."
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."
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" \
|| fail "production deploy workflow must document that it uses the same image tag as staging."
grep -Fq "TRAEFIK_PUBLIC_NETWORK=traefik-public" "$bundle_script" \
|| fail "shared bundle script must write Traefik public network env."
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."
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" \
|| 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" \
|| 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" \
|| 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
fail "image deploy workflows/scripts must not depend on Harbor registry login or compose pull."
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."
fi
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"
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" == "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" \
|| 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"