From b18dc5a0ac3087e3120c151a4c148637f7782f5e Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Wed, 3 Jun 2026 15:14:46 +0900 Subject: [PATCH] feat(admin): config-sync mirror mode + opt-in user sync (ADR 0003, PR 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the config-sync import/export beyond additive merge. mirror mode — makes the target match the bundle exactly. On top of the merge it reconciles each role's grants (revoking permissions the bundle omits) and deletes definitional entities absent from the bundle. There are no FK cascades between roles/permissions/users, so deletes clean their own join rows and run in a safe order: - menus: deleted leaf-first (a child before its parent); - roles: a role still assigned to any user is SKIPPED (mirror never strips a user's role), else its grants are revoked then it is deleted; - permissions: revoked from the tenant's roles, then deleted. ImportResult.Section gains `deleted` and `skipped` to report this. user sync (includeUsers, opt-in, default off) — export carries users by login id with NO password (just email, status, role codes); import is create-only: a missing user is created with no usable password and mustChangePassword, then assigned roles by code. An existing user is never overwritten (reported as skipped). ConfigBundle gains an optional `users` list (5-arg constructor kept for definitional-only bundles). Both stay gated by ConfigSyncAutoConfiguration (off unless devslab.kit.config-sync.enabled=true) and refused under a production profile. mirror is intended for single-tenant-per-deployment use. Tests: mirror delete/skip/grant-reconcile and user export/create/ idempotent/never-overwrite, over real Postgres. Full suite green (54 tests, 0 failures/0 errors). --- .../kit/admin/config/ConfigBundle.java | 53 +++- .../kit/admin/config/ConfigExportService.java | 43 ++- .../kit/admin/config/ConfigImportService.java | 274 +++++++++++++++--- .../admin/config/ConfigSyncController.java | 28 +- .../kit/admin/config/ImportResult.java | 29 +- .../ConfigSyncAutoConfiguration.java | 14 +- .../devslab/kit/sample/ConfigSyncTests.java | 131 +++++++++ 7 files changed, 512 insertions(+), 60 deletions(-) diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigBundle.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigBundle.java index 1b269ba..39b6b76 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigBundle.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigBundle.java @@ -3,31 +3,56 @@ import java.util.List; /** - * Portable, environment-independent snapshot of the kit's definitional - * platform config — keyed entirely by natural codes, never DB UUIDs — so it can be - * exported from one environment and imported into another (or committed to git and - * applied on deploy). See ADR 0003. + * Portable, environment-independent snapshot of the kit's platform config — keyed + * entirely by natural codes, never DB UUIDs — so it can be exported from one + * environment and imported into another (or committed to git and applied on deploy). + * See ADR 0003. * - *

Scope: permissions, roles (+ their permission codes), and menus. Operational data - * (users, assignments) and history (audit logs) are deliberately excluded; ABAC policies - * are code, not data. + *

Definitional config (permissions, roles + their permission codes, + * menus) is always present. Users are operational data and are only + * included when explicitly requested (export {@code includeUsers=true}); even then no + * secret is ever carried — only login id, email, status and assigned role codes. History + * (audit logs) is never included; ABAC policies are code, not data. */ public record ConfigBundle( int version, String tenantId, List permissions, List roles, - List menus + List menus, + List users ) { /** Bump when the bundle shape changes incompatibly. */ public static final int CURRENT_VERSION = 1; + /** Null-normalises every list so a partially-populated bundle (e.g. hand-pasted JSON) is safe. */ + public ConfigBundle { + permissions = permissions == null ? List.of() : permissions; + roles = roles == null ? List.of() : roles; + menus = menus == null ? List.of() : menus; + users = users == null ? List.of() : users; + } + + /** Definitional-only bundle (no users) — the common case. */ + public ConfigBundle( + int version, + String tenantId, + List permissions, + List roles, + List menus + ) { + this(version, tenantId, permissions, roles, menus, List.of()); + } + public record PermissionDef(String code, String description) { } /** A role and the permission codes it grants (resolved cross-environment). */ public record RoleDef(String code, String name, List permissionCodes) { + public RoleDef { + permissionCodes = permissionCodes == null ? List.of() : permissionCodes; + } } /** A menu item; {@code parentCode} / {@code requiredPermissionCode} reference by code. */ @@ -41,4 +66,16 @@ public record MenuDef( int displayOrder ) { } + + /** + * A user account — natural key is {@code loginId} within the tenant. Carries no + * password (transported users are created with no usable password and must have one + * set by an admin) and no environment-specific public id. {@code roleCodes} are the + * roles to assign by code. + */ + public record UserDef(String loginId, String email, String status, List roleCodes) { + public UserDef { + roleCodes = roleCodes == null ? List.of() : roleCodes; + } + } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigExportService.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigExportService.java index 57e5467..9795560 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigExportService.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigExportService.java @@ -11,10 +11,14 @@ import kr.devslab.kit.access.core.service.PermissionAdminService; import kr.devslab.kit.access.core.service.RoleAdminService; import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; import kr.devslab.kit.admin.config.ConfigBundle.MenuDef; import kr.devslab.kit.admin.config.ConfigBundle.PermissionDef; import kr.devslab.kit.admin.config.ConfigBundle.RoleDef; +import kr.devslab.kit.admin.config.ConfigBundle.UserDef; import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.identity.UserAccountView; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; import kr.devslab.kit.menu.core.entity.PlatformMenuEntity; import kr.devslab.kit.menu.core.service.MenuAdminService; @@ -22,6 +26,10 @@ * Builds a {@link ConfigBundle} from the live database for a tenant, translating every * DB id into the corresponding natural code so the result is portable across environments * (ADR 0003). Read-only. + * + *

Definitional config (permissions, roles, menus) is always exported. Users are + * exported only when {@code includeUsers} is set, and even then carry no password — + * just login id, email, status and assigned role codes. */ public class ConfigExportService { @@ -29,20 +37,31 @@ public class ConfigExportService { private final RoleAdminService roles; private final RolePermissionService rolePermissions; private final MenuAdminService menus; + private final PlatformUserAccountAdminService userAccounts; + private final UserRoleService userRoles; public ConfigExportService( PermissionAdminService permissions, RoleAdminService roles, RolePermissionService rolePermissions, - MenuAdminService menus + MenuAdminService menus, + PlatformUserAccountAdminService userAccounts, + UserRoleService userRoles ) { this.permissions = permissions; this.roles = roles; this.rolePermissions = rolePermissions; this.menus = menus; + this.userAccounts = userAccounts; + this.userRoles = userRoles; } + /** Definitional-only export (no users). */ public ConfigBundle export(TenantId tenantId) { + return export(tenantId, false); + } + + public ConfigBundle export(TenantId tenantId, boolean includeUsers) { List permEntities = permissions.listAll(); Map permIdToCode = permEntities.stream() .collect(Collectors.toMap(PlatformPermissionEntity::getId, PlatformPermissionEntity::getCode)); @@ -74,7 +93,10 @@ public ConfigBundle export(TenantId tenantId) { menu.getSortOrder())) .toList(); - return new ConfigBundle(ConfigBundle.CURRENT_VERSION, tenantId.value(), permissionDefs, roleDefs, menuDefs); + List userDefs = includeUsers ? exportUsers(tenantId) : List.of(); + + return new ConfigBundle( + ConfigBundle.CURRENT_VERSION, tenantId.value(), permissionDefs, roleDefs, menuDefs, userDefs); } private List permissionCodesFor(Role role, Map permIdToCode) { @@ -84,4 +106,21 @@ private List permissionCodesFor(Role role, Map permIdToCod .sorted() .toList(); } + + private List exportUsers(TenantId tenantId) { + Map roleCodeById = roles.listByTenant(tenantId).stream() + .collect(Collectors.toMap(role -> role.id().value(), Role::code)); + return userAccounts.listByTenant(tenantId).stream() + .sorted(Comparator.comparing(UserAccountView::loginId)) + .map(user -> new UserDef( + user.loginId(), + user.email(), + user.status().name(), + userRoles.findRoleIdsForUser(user.id()).stream() + .map(roleId -> roleCodeById.get(roleId.value())) + .filter(Objects::nonNull) + .sorted() + .toList())) + .toList(); + } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigImportService.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigImportService.java index 8c47205..ba203d9 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigImportService.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigImportService.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -15,25 +16,50 @@ import kr.devslab.kit.access.core.service.PermissionAdminService; import kr.devslab.kit.access.core.service.RoleAdminService; import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; import kr.devslab.kit.admin.config.ConfigBundle.MenuDef; import kr.devslab.kit.admin.config.ConfigBundle.PermissionDef; import kr.devslab.kit.admin.config.ConfigBundle.RoleDef; +import kr.devslab.kit.admin.config.ConfigBundle.UserDef; import kr.devslab.kit.core.id.MenuId; import kr.devslab.kit.core.id.PermissionId; +import kr.devslab.kit.core.id.RoleId; import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.UserAccountView; +import kr.devslab.kit.identity.UserStatus; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; import kr.devslab.kit.menu.core.entity.PlatformMenuEntity; import kr.devslab.kit.menu.core.service.MenuAdminService; import org.springframework.transaction.annotation.Transactional; /** - * Applies a {@link ConfigBundle} to the live database, matching by natural code - * (ADR 0003). Prototype scope: {@code merge} mode — additive, idempotent upsert that - * never deletes. {@code dryRun} computes the diff without writing anything. The whole - * apply runs in one transaction. + * Applies a {@link ConfigBundle} to the live database, matching by natural code (ADR 0003). + * {@code dryRun} computes the diff without writing anything; the whole apply runs in one + * transaction. * - *

Menus are processed parent-before-child (the parent must exist to create a child; - * a menu's parent cannot be changed by update), so a bundle whose menus are in any order - * still imports correctly. + *

merge (the default) is additive: it creates and updates, never deletes, + * and never revokes a role's existing permission grants. + * + *

mirror makes the target match the bundle exactly: in addition to the + * merge, it reconciles each role's grants (revoking permissions not in the bundle) and + * deletes definitional entities absent from the bundle. There are no FK cascades + * between roles/permissions/users, so deletes clean their own join rows: + *

+ * + *

Menus are processed parent-before-child on create so a bundle whose menus are in any + * order still imports correctly. + * + *

Users are only touched when {@code includeUsers} is set, and only ever created — an + * existing user is left untouched ({@code skipped}) and no password is ever carried: a + * created user has no usable password and must have one set by an admin. */ public class ConfigImportService { @@ -44,77 +70,112 @@ public class ConfigImportService { private final RoleAdminService roles; private final RolePermissionService rolePermissions; private final MenuAdminService menus; + private final PlatformUserAccountAdminService userAccounts; + private final UserRoleService userRoles; public ConfigImportService( PermissionAdminService permissions, RoleAdminService roles, RolePermissionService rolePermissions, - MenuAdminService menus + MenuAdminService menus, + PlatformUserAccountAdminService userAccounts, + UserRoleService userRoles ) { this.permissions = permissions; this.roles = roles; this.rolePermissions = rolePermissions; this.menus = menus; + this.userAccounts = userAccounts; + this.userRoles = userRoles; } + /** Definitional-only apply (no user sync). */ @Transactional public ImportResult apply(ConfigBundle bundle, String mode, boolean dryRun) { + return apply(bundle, mode, dryRun, false); + } + + @Transactional + public ImportResult apply(ConfigBundle bundle, String mode, boolean dryRun, boolean includeUsers) { + boolean mirror = "mirror".equalsIgnoreCase(mode); TenantId tenant = TenantId.of(bundle.tenantId()); - ImportResult.Section permSection = importPermissions(bundle.permissions(), dryRun); - ImportResult.Section roleSection = importRoles(tenant, bundle.roles(), dryRun); - ImportResult.Section menuSection = importMenus(tenant, bundle.menus(), dryRun); + Upsert perms = importPermissions(bundle.permissions(), dryRun); + Upsert roleUpsert = importRoles(tenant, bundle.roles(), dryRun, mirror); + Upsert menuUpsert = importMenus(tenant, bundle.menus(), dryRun); + ImportResult.Section users = includeUsers + ? importUsers(tenant, bundle.users(), dryRun) + : ImportResult.Section.EMPTY; + + Deletions menuDel = Deletions.EMPTY; + Deletions roleDel = Deletions.EMPTY; + Deletions permDel = Deletions.EMPTY; + if (mirror) { + // Order matters with no FK cascades: menus, then roles, then permissions. + menuDel = deleteMenusNotIn(tenant, codes(bundle.menus(), MenuDef::code), dryRun); + roleDel = deleteRolesNotIn(tenant, codes(bundle.roles(), RoleDef::code), dryRun); + permDel = deletePermissionsNotIn(tenant, codes(bundle.permissions(), PermissionDef::code), dryRun); + } - return new ImportResult(dryRun, "merge", permSection, roleSection, menuSection); + return new ImportResult( + dryRun, + mirror ? "mirror" : "merge", + ImportResult.Section.of(perms.created, perms.updated, permDel.deleted, permDel.skipped), + ImportResult.Section.of(roleUpsert.created, roleUpsert.updated, roleDel.deleted, roleDel.skipped), + ImportResult.Section.of(menuUpsert.created, menuUpsert.updated, menuDel.deleted, menuDel.skipped), + users); } - private ImportResult.Section importPermissions(List defs, boolean dryRun) { + // ── Upsert (create / update) ──────────────────────────────────────────── + + private Upsert importPermissions(List defs, boolean dryRun) { Map byCode = permissions.listAll().stream() .collect(Collectors.toMap(PlatformPermissionEntity::getCode, Function.identity())); - List created = new ArrayList<>(); - List updated = new ArrayList<>(); + Upsert out = new Upsert(); for (PermissionDef def : defs) { PlatformPermissionEntity existing = byCode.get(def.code()); if (existing == null) { - created.add(def.code()); + out.created.add(def.code()); if (!dryRun) { permissions.create(def.code(), def.description()); } } else if (!Objects.equals(existing.getDescription(), def.description())) { - updated.add(def.code()); + out.updated.add(def.code()); if (!dryRun) { permissions.updateDescription(PermissionId.of(existing.getId()), def.description()); } } } - return ImportResult.Section.of(created, updated); + return out; } - private ImportResult.Section importRoles(TenantId tenant, List defs, boolean dryRun) { + private Upsert importRoles(TenantId tenant, List defs, boolean dryRun, boolean mirror) { Map roleByCode = roles.listByTenant(tenant).stream() .collect(Collectors.toMap(Role::code, Function.identity())); - // id -> code over the permissions visible before this run (enough to read a role's current grants) Map permCodeById = permissions.listAll().stream() .collect(Collectors.toMap(PlatformPermissionEntity::getId, PlatformPermissionEntity::getCode)); - // code -> id for granting (after permission creates; in dry-run only pre-existing ones have ids) Map permIdByCode = permissions.listAll().stream() .collect(Collectors.toMap(PlatformPermissionEntity::getCode, PlatformPermissionEntity::getId)); - List created = new ArrayList<>(); - List updated = new ArrayList<>(); + Upsert out = new Upsert(); for (RoleDef def : defs) { Role existing = roleByCode.get(def.code()); boolean isNew = existing == null; Set currentCodes = isNew ? Set.of() : currentPermissionCodes(existing, permCodeById); + Set wantCodes = new HashSet<>(def.permissionCodes()); List toGrant = def.permissionCodes().stream() .filter(code -> !currentCodes.contains(code)) .toList(); + // mirror also tightens grants: revoke anything the role has that the bundle doesn't. + List toRevoke = mirror + ? currentCodes.stream().filter(code -> !wantCodes.contains(code)).toList() + : List.of(); boolean renamed = !isNew && !Objects.equals(existing.name(), def.name()); if (isNew) { - created.add(def.code()); - } else if (renamed || !toGrant.isEmpty()) { - updated.add(def.code()); + out.created.add(def.code()); + } else if (renamed || !toGrant.isEmpty() || !toRevoke.isEmpty()) { + out.updated.add(def.code()); } if (!dryRun) { @@ -128,9 +189,15 @@ private ImportResult.Section importRoles(TenantId tenant, List defs, bo rolePermissions.grant(role.id(), PermissionId.of(permId)); } } + for (String code : toRevoke) { + UUID permId = permIdByCode.get(code); + if (permId != null) { + rolePermissions.revoke(role.id(), PermissionId.of(permId)); + } + } } } - return ImportResult.Section.of(created, updated); + return out; } private Set currentPermissionCodes(Role role, Map permCodeById) { @@ -140,14 +207,13 @@ private Set currentPermissionCodes(Role role, Map permCode .collect(Collectors.toSet()); } - private ImportResult.Section importMenus(TenantId tenant, List defs, boolean dryRun) { + private Upsert importMenus(TenantId tenant, List defs, boolean dryRun) { Map byCode = menus.listByTenant(tenant).stream() .collect(Collectors.toMap(PlatformMenuEntity::getCode, Function.identity())); Map idByCode = new HashMap<>(); byCode.forEach((code, entity) -> idByCode.put(code, entity.getId())); - List created = new ArrayList<>(); - List updated = new ArrayList<>(); + Upsert out = new Upsert(); List pending = new ArrayList<>(defs); boolean progress = true; @@ -162,7 +228,7 @@ private ImportResult.Section importMenus(TenantId tenant, List defs, bo } PlatformMenuEntity existing = byCode.get(def.code()); if (existing == null) { - created.add(def.code()); + out.created.add(def.code()); if (dryRun) { idByCode.put(def.code(), DRY_RUN_ID); } else { @@ -175,7 +241,7 @@ private ImportResult.Section importMenus(TenantId tenant, List defs, bo } } else { if (menuChanged(existing, def)) { - updated.add(def.code()); + out.updated.add(def.code()); if (!dryRun) { menus.update(MenuId.of(existing.getId()), def.label(), def.path(), def.displayOrder(), def.requiredPermissionCode(), def.icon()); @@ -188,7 +254,7 @@ private ImportResult.Section importMenus(TenantId tenant, List defs, bo } } // pending leftovers reference a parent that is neither in the DB nor the bundle — skip them. - return ImportResult.Section.of(created, updated); + return out; } private boolean menuChanged(PlatformMenuEntity e, MenuDef def) { @@ -198,4 +264,146 @@ private boolean menuChanged(PlatformMenuEntity e, MenuDef def) { || !Objects.equals(e.getRequiredPermissionCode(), def.requiredPermissionCode()) || e.getSortOrder() != def.displayOrder(); } + + // ── User sync (create-only) ────────────────────────────────────────────── + + private ImportResult.Section importUsers(TenantId tenant, List defs, boolean dryRun) { + Map byLogin = userAccounts.listByTenant(tenant).stream() + .collect(Collectors.toMap(UserAccountView::loginId, Function.identity(), (a, b) -> a)); + Map roleIdByCode = roles.listByTenant(tenant).stream() + .collect(Collectors.toMap(Role::code, role -> role.id().value())); + + List created = new ArrayList<>(); + List skipped = new ArrayList<>(); + for (UserDef def : defs) { + if (byLogin.containsKey(def.loginId())) { + skipped.add(def.loginId()); // never overwrite an existing user + continue; + } + created.add(def.loginId()); + if (!dryRun) { + // No secret is transported: create with no usable password + mustChangePassword. + UserAccountView user = userAccounts.create( + tenant, def.loginId(), def.email(), null, "LOCAL", true); + for (String roleCode : def.roleCodes()) { + UUID roleId = roleIdByCode.get(roleCode); + if (roleId != null) { + userRoles.assign(user.id(), RoleId.of(roleId), tenant); + } + } + applyStatus(user.id(), def.status()); + } + } + return ImportResult.Section.of(created, List.of(), List.of(), skipped); + } + + private void applyStatus(UserId id, String status) { + if (status == null) { + return; + } + try { + UserStatus parsed = UserStatus.valueOf(status); + if (parsed != UserStatus.ACTIVE) { // create() already sets ACTIVE + userAccounts.setStatus(id, parsed); + } + } catch (IllegalArgumentException ignored) { + // unknown status string — leave the account ACTIVE + } + } + + // ── Mirror deletes ─────────────────────────────────────────────────────── + + private Deletions deleteMenusNotIn(TenantId tenant, Set keep, boolean dryRun) { + List pending = menus.listByTenant(tenant).stream() + .filter(m -> !keep.contains(m.getCode())) + .collect(Collectors.toCollection(ArrayList::new)); + List deleted = new ArrayList<>(); + boolean progress = true; + while (!pending.isEmpty() && progress) { + progress = false; + Iterator it = pending.iterator(); + while (it.hasNext()) { + PlatformMenuEntity menu = it.next(); + boolean hasPendingChild = pending.stream() + .anyMatch(other -> menu.getId().equals(other.getParentId())); + if (hasPendingChild) { + continue; // delete children first + } + deleted.add(menu.getCode()); + if (!dryRun) { + menus.delete(MenuId.of(menu.getId())); + } + it.remove(); + progress = true; + } + } + // any leftover (only possible on a parent cycle, which the schema disallows) — delete anyway + for (PlatformMenuEntity menu : pending) { + deleted.add(menu.getCode()); + if (!dryRun) { + menus.delete(MenuId.of(menu.getId())); + } + } + return new Deletions(deleted, List.of()); + } + + private Deletions deleteRolesNotIn(TenantId tenant, Set keep, boolean dryRun) { + Set assignedRoleIds = new HashSet<>(); + for (UserAccountView user : userAccounts.listByTenant(tenant)) { + userRoles.findRoleIdsForUser(user.id()).forEach(rid -> assignedRoleIds.add(rid.value())); + } + List deleted = new ArrayList<>(); + List skipped = new ArrayList<>(); + for (Role role : roles.listByTenant(tenant)) { + if (keep.contains(role.code())) { + continue; + } + if (assignedRoleIds.contains(role.id().value())) { + skipped.add(role.code()); // still assigned to a user — mirror won't strip it + continue; + } + deleted.add(role.code()); + if (!dryRun) { + for (PermissionId pid : rolePermissions.findPermissionIdsForRole(role.id())) { + rolePermissions.revoke(role.id(), pid); + } + roles.delete(role.id()); + } + } + return new Deletions(deleted, skipped); + } + + private Deletions deletePermissionsNotIn(TenantId tenant, Set keep, boolean dryRun) { + List tenantRoles = roles.listByTenant(tenant); + List deleted = new ArrayList<>(); + for (PlatformPermissionEntity perm : permissions.listAll()) { + if (keep.contains(perm.getCode())) { + continue; + } + deleted.add(perm.getCode()); + if (!dryRun) { + PermissionId pid = PermissionId.of(perm.getId()); + for (Role role : tenantRoles) { + rolePermissions.revoke(role.id(), pid); // idempotent if not granted + } + permissions.delete(pid); + } + } + return new Deletions(deleted, List.of()); + } + + private static Set codes(List defs, Function code) { + return defs.stream().map(code).collect(Collectors.toSet()); + } + + /** Mutable accumulator for create/update codes during a single upsert pass. */ + private static final class Upsert { + final List created = new ArrayList<>(); + final List updated = new ArrayList<>(); + } + + /** Immutable result of a mirror delete pass. */ + private record Deletions(List deleted, List skipped) { + static final Deletions EMPTY = new Deletions(List.of(), List.of()); + } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigSyncController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigSyncController.java index 82bcc4e..2de1331 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigSyncController.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ConfigSyncController.java @@ -14,9 +14,9 @@ * {@code ConfigSyncAutoConfiguration} — the whole surface is off unless * {@code devslab.kit.config-sync.enabled=true}. * - *

Prototype scope: {@code export} + {@code import} ({@code merge} mode, dry-run by - * default). {@code mirror} mode, optional user sync, and the dev-profile / prod fail-fast - * gating follow per the ADR's PR breakdown. + *

{@code export} + {@code import} support {@code merge} / {@code mirror} modes (dry-run by + * default) and optional user sync ({@code includeUsers}). The whole surface is refused under + * a production profile (ADR 0003 §5). */ @RestController @RequestMapping(AdminApiPaths.BASE + "/config") @@ -30,22 +30,32 @@ public ConfigSyncController(ConfigExportService exportService, ConfigImportServi this.importService = importService; } - /** Export a tenant's definitional config as a portable, code-keyed bundle. */ + /** + * Export a tenant's config as a portable, code-keyed bundle. Definitional config + * (permissions, roles, menus) is always included; users only when + * {@code includeUsers=true} (and even then with no password). + */ @GetMapping("/export") - public ConfigBundle export(@RequestParam String tenantId) { - return exportService.export(TenantId.of(tenantId)); + public ConfigBundle export( + @RequestParam String tenantId, + @RequestParam(defaultValue = "false") boolean includeUsers + ) { + return exportService.export(TenantId.of(tenantId), includeUsers); } /** * Apply a bundle by natural code. {@code dryRun=true} (the default) returns the diff - * without writing. {@code mode=merge} (the default) is additive and never deletes. + * without writing. {@code mode=merge} (the default) is additive and never deletes; + * {@code mode=mirror} also deletes entities absent from the bundle. {@code includeUsers=true} + * additionally creates missing users from the bundle (existing users are never overwritten). */ @PostMapping("/import") public ImportResult importConfig( @RequestBody ConfigBundle bundle, @RequestParam(defaultValue = "merge") String mode, - @RequestParam(defaultValue = "true") boolean dryRun + @RequestParam(defaultValue = "true") boolean dryRun, + @RequestParam(defaultValue = "false") boolean includeUsers ) { - return importService.apply(bundle, mode, dryRun); + return importService.apply(bundle, mode, dryRun, includeUsers); } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ImportResult.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ImportResult.java index 530e6d8..c1d76c6 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ImportResult.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/config/ImportResult.java @@ -4,20 +4,41 @@ /** * Outcome of a {@link ConfigImportService} run — the diff of what was (or, in dry-run, - * would be) created/updated per type. Codes only; never UUIDs (ADR 0003). + * would be) created/updated/deleted per type. Codes only; never UUIDs (ADR 0003). + * + *

{@code deleted} is only ever non-empty in {@code mirror} mode. {@code skipped} reports + * entities that were intentionally left untouched — a role still assigned to users (mirror + * refuses to strip it) or a user that already exists (import never overwrites users). + * + *

{@code users} is empty unless the run requested user sync ({@code includeUsers=true}). */ public record ImportResult( boolean dryRun, String mode, Section permissions, Section roles, - Section menus + Section menus, + Section users ) { - public record Section(List created, List updated) { + public record Section(List created, List updated, List deleted, List skipped) { + + public static final Section EMPTY = new Section(List.of(), List.of(), List.of(), List.of()); + + public Section { + created = List.copyOf(created); + updated = List.copyOf(updated); + deleted = List.copyOf(deleted); + skipped = List.copyOf(skipped); + } public static Section of(List created, List updated) { - return new Section(List.copyOf(created), List.copyOf(updated)); + return new Section(created, updated, List.of(), List.of()); + } + + public static Section of( + List created, List updated, List deleted, List skipped) { + return new Section(created, updated, deleted, skipped); } } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/ConfigSyncAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/ConfigSyncAutoConfiguration.java index 19e501f..86afddb 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/ConfigSyncAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/ConfigSyncAutoConfiguration.java @@ -3,9 +3,11 @@ import kr.devslab.kit.access.core.service.PermissionAdminService; import kr.devslab.kit.access.core.service.RoleAdminService; import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; import kr.devslab.kit.admin.config.ConfigExportService; import kr.devslab.kit.admin.config.ConfigImportService; import kr.devslab.kit.admin.config.ConfigSyncController; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; import kr.devslab.kit.menu.core.service.MenuAdminService; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -35,9 +37,11 @@ ConfigExportService configExportService( PermissionAdminService permissions, RoleAdminService roles, RolePermissionService rolePermissions, - MenuAdminService menus + MenuAdminService menus, + PlatformUserAccountAdminService userAccounts, + UserRoleService userRoles ) { - return new ConfigExportService(permissions, roles, rolePermissions, menus); + return new ConfigExportService(permissions, roles, rolePermissions, menus, userAccounts, userRoles); } @Bean @@ -45,9 +49,11 @@ ConfigImportService configImportService( PermissionAdminService permissions, RoleAdminService roles, RolePermissionService rolePermissions, - MenuAdminService menus + MenuAdminService menus, + PlatformUserAccountAdminService userAccounts, + UserRoleService userRoles ) { - return new ConfigImportService(permissions, roles, rolePermissions, menus); + return new ConfigImportService(permissions, roles, rolePermissions, menus, userAccounts, userRoles); } /** Refuses to start if config sync is enabled under a production profile (ADR 0003 §5). */ diff --git a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/ConfigSyncTests.java b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/ConfigSyncTests.java index 2f6861d..6a6267c 100644 --- a/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/ConfigSyncTests.java +++ b/devslab-kit-sample-app/src/test/java/kr/devslab/kit/sample/ConfigSyncTests.java @@ -7,15 +7,19 @@ import kr.devslab.kit.access.core.service.PermissionAdminService; import kr.devslab.kit.access.core.service.RoleAdminService; import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; import kr.devslab.kit.admin.config.ConfigBundle; import kr.devslab.kit.admin.config.ConfigBundle.MenuDef; import kr.devslab.kit.admin.config.ConfigBundle.PermissionDef; import kr.devslab.kit.admin.config.ConfigBundle.RoleDef; +import kr.devslab.kit.admin.config.ConfigBundle.UserDef; import kr.devslab.kit.admin.config.ConfigExportService; import kr.devslab.kit.admin.config.ConfigImportService; import kr.devslab.kit.admin.config.ImportResult; import kr.devslab.kit.core.id.PermissionId; +import kr.devslab.kit.core.id.RoleId; import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; import kr.devslab.kit.menu.core.service.MenuAdminService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -51,6 +55,12 @@ class ConfigSyncTests { @Autowired private MenuAdminService menus; + @Autowired + private PlatformUserAccountAdminService userAccounts; + + @Autowired + private UserRoleService userRoles; + private final TenantId tenant = TenantId.of("default"); @Test @@ -124,6 +134,127 @@ void importCreatesByCodeRespectsParentOrderAndIsIdempotent() { assertThat(again.menus().created()).isEmpty(); } + @Test + void mirrorDeletesExtrasReconcilesGrantsAndSkipsRolesAssignedToUsers() { + // Seed: a permission to keep + one to drop, a role granted both, two menus. + permissions.create("mir.keep", "keep me"); + permissions.create("mir.drop", "drop me"); + UUID dropPermId = permissionId("mir.drop"); + var role = roles.create(tenant, "MIR_ROLE", "Mirror Role"); + rolePermissions.grant(role.id(), PermissionId.of(permissionId("mir.keep"))); + rolePermissions.grant(role.id(), PermissionId.of(dropPermId)); + menus.create(tenant, "mir-keep", "Keep", "/mk", null, 1, null, null); + menus.create(tenant, "mir-drop", "Drop", "/md", null, 2, null, null); + // A role assigned to a user must survive mirror (skipped, never stripped). + var inUse = roles.create(tenant, "MIR_INUSE", "In Use"); + var user = userAccounts.create(tenant, "mir-user", "mir@example.com", "pw-12345678", "LOCAL"); + userRoles.assign(user.id(), inUse.id(), tenant); + + // Build the mirror bundle from the current full export, minus what should go: the + // mir.drop permission (and its grant on MIR_ROLE), the mir-drop menu, the MIR_INUSE + // role. Everything else in the DB stays in the bundle, so mirror leaves it alone. + ConfigBundle full = exportService.export(tenant); + var perms = full.permissions().stream() + .filter(p -> !p.code().equals("mir.drop")).toList(); + var rolesMinus = full.roles().stream() + .filter(r -> !r.code().equals("MIR_INUSE")) + .map(r -> r.code().equals("MIR_ROLE") + ? new RoleDef(r.code(), r.name(), + r.permissionCodes().stream().filter(c -> !c.equals("mir.drop")).toList()) + : r) + .toList(); + var menusMinus = full.menus().stream() + .filter(m -> !m.code().equals("mir-drop")).toList(); + ConfigBundle bundle = new ConfigBundle(full.version(), full.tenantId(), perms, rolesMinus, menusMinus); + + // dry-run reports the deletes/skips without writing anything. + ImportResult dry = importService.apply(bundle, "mirror", true); + assertThat(dry.mode()).isEqualTo("mirror"); + assertThat(dry.permissions().deleted()).contains("mir.drop"); + assertThat(dry.menus().deleted()).contains("mir-drop"); + assertThat(dry.roles().skipped()).contains("MIR_INUSE"); + assertThat(dry.roles().deleted()).doesNotContain("MIR_INUSE"); + assertThat(permissions.listAll()).anyMatch(p -> p.getCode().equals("mir.drop")); + + // apply + ImportResult applied = importService.apply(bundle, "mirror", false); + assertThat(applied.permissions().deleted()).contains("mir.drop"); + assertThat(applied.menus().deleted()).contains("mir-drop"); + assertThat(applied.roles().updated()).contains("MIR_ROLE"); // grant reconciled + assertThat(applied.roles().skipped()).contains("MIR_INUSE"); + + // mir.drop permission gone, mir.keep stays. + assertThat(permissions.listAll()).noneMatch(p -> p.getCode().equals("mir.drop")); + assertThat(permissions.listAll()).anyMatch(p -> p.getCode().equals("mir.keep")); + // MIR_ROLE no longer grants the dropped permission. + assertThat(rolePermissions.findPermissionIdsForRole(role.id())) + .extracting(PermissionId::value) + .doesNotContain(dropPermId); + // mir-drop menu gone, mir-keep stays. + assertThat(menus.listByTenant(tenant)).noneMatch(m -> m.getCode().equals("mir-drop")); + assertThat(menus.listByTenant(tenant)).anyMatch(m -> m.getCode().equals("mir-keep")); + // MIR_INUSE survived (still assigned to a user). + assertThat(roles.listByTenant(tenant)).anyMatch(r -> r.code().equals("MIR_INUSE")); + } + + @Test + void userSyncExportsWithoutSecretsAndCreatesMissingUsersOnly() { + permissions.create("us.read", "user-sync read"); + var role = roles.create(tenant, "US_ROLE", "User Sync Role"); + rolePermissions.grant(role.id(), PermissionId.of(permissionId("us.read"))); + var existing = userAccounts.create(tenant, "us-existing", "ex@example.com", "pw-12345678", "LOCAL"); + userRoles.assign(existing.id(), role.id(), tenant); + + // Definitional export omits users; includeUsers carries them — by code, no password. + assertThat(exportService.export(tenant).users()).isEmpty(); + ConfigBundle withUsers = exportService.export(tenant, true); + assertThat(withUsers.users()) + .filteredOn(u -> u.loginId().equals("us-existing")) + .singleElement() + .satisfies(u -> { + assertThat(u.email()).isEqualTo("ex@example.com"); + assertThat(u.status()).isEqualTo("ACTIVE"); + assertThat(u.roleCodes()).contains("US_ROLE"); + }); + + // Import a brand-new user (create-only). includeUsers=true is required. + ConfigBundle bundle = new ConfigBundle( + ConfigBundle.CURRENT_VERSION, + "default", + List.of(), + List.of(new RoleDef("US_ROLE", "User Sync Role", List.of("us.read"))), + List.of(), + List.of(new UserDef("us-new", "new@example.com", "ACTIVE", List.of("US_ROLE")))); + + // dry-run: reports, writes nothing. + ImportResult dry = importService.apply(bundle, "merge", true, true); + assertThat(dry.users().created()).contains("us-new"); + assertThat(userAccounts.listByTenant(tenant)).noneMatch(u -> u.loginId().equals("us-new")); + + // apply: creates the user and assigns the role by code. + ImportResult applied = importService.apply(bundle, "merge", false, true); + assertThat(applied.users().created()).contains("us-new"); + var created = userAccounts.listByTenant(tenant).stream() + .filter(u -> u.loginId().equals("us-new")).findFirst().orElseThrow(); + assertThat(userRoles.findRoleIdsForUser(created.id())) + .extracting(RoleId::value) + .contains(role.id().value()); + + // idempotent + never overwrite: re-apply reports skipped, not created. + ImportResult again = importService.apply(bundle, "merge", false, true); + assertThat(again.users().created()).isEmpty(); + assertThat(again.users().skipped()).contains("us-new"); + + // includeUsers=false ignores bundle users entirely. + ConfigBundle ignoreUsers = new ConfigBundle( + ConfigBundle.CURRENT_VERSION, "default", + List.of(), List.of(), List.of(), + List.of(new UserDef("us-ignored", "ig@example.com", "ACTIVE", List.of()))); + ImportResult noUsers = importService.apply(ignoreUsers, "merge", false, false); + assertThat(noUsers.users().created()).isEmpty(); + assertThat(userAccounts.listByTenant(tenant)).noneMatch(u -> u.loginId().equals("us-ignored")); + } + private UUID permissionId(String code) { return permissions.listAll().stream() .filter(p -> p.getCode().equals(code))