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))