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 — 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); otherwise its permission grants are revoked, then it is deleted;
+ * - permissions — revoked from this tenant's roles, then deleted. Note permissions
+ * are global (not tenant-scoped): mirror is intended for single-tenant-per-deployment use
+ * and is dev/staging-only (it is refused under a production profile).
+ *
+ *
+ * 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))